├── .formatter.exs ├── .github ├── issue_template.md ├── pull_request_template.md └── workflows │ └── ci.yml ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── comment_directives.md ├── control_flow_macros.md ├── credo.md ├── deprecations.md ├── mix_configs.md ├── module_directives.md ├── pipes.md └── styles.md ├── lib ├── alias_env.ex ├── style.ex ├── style │ ├── blocks.ex │ ├── comment_directives.ex │ ├── configs.ex │ ├── defs.ex │ ├── deprecations.ex │ ├── module_directives.ex │ ├── pipes.ex │ └── single_node.ex ├── style_error.ex ├── styler.ex ├── styler │ └── config.ex └── zipper.ex ├── mix.exs ├── mix.lock └── test ├── config_test.exs ├── style ├── blocks_test.exs ├── comment_directives_test.exs ├── configs_test.exs ├── defs_test.exs ├── deprecations_test.exs ├── module_directives │ └── alias_lifting_test.exs ├── module_directives_test.exs ├── pipes_test.exs ├── single_node_test.exs └── styles_test.exs ├── style_test.exs ├── support └── style_case.ex ├── test_helper.exs └── zipper_test.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: [ 3 | "{mix,.formatter}.exs", 4 | "{config,lib,test}/**/*.{ex,exs}" 5 | ], 6 | locals_without_parens: [ 7 | assert_style: 1, 8 | assert_style: 2 9 | ], 10 | plugins: [Styler], 11 | styler: [alias_lifting_exclude: []], 12 | line_length: 122 13 | ] 14 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | ## Versions 2 | 3 | * Elixir: use `elixir --version` 4 | * Styler: use `mix deps | grep locked | grep styler` 5 | 6 | ## Example Input 7 | 8 | ```elixir 9 | 10 | ``` 11 | 12 | ## Stacktrace / Current Behaviour 13 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | - [ ] Please [sign Adobe's CLA](http://opensource.adobe.com/cla.html) if this is your first time contributing to an Adobe open source repo. Thanks! 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | name: Build & Test 3 | env: 4 | MIX_ENV: test 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | name: Ex${{matrix.elixir}}/OTP${{matrix.otp}} 10 | strategy: 11 | matrix: 12 | elixir: ["1.15.8", "1.16.3", "1.17.3", "1.18.2"] 13 | otp: ["25.3.2", "26.2.5", "27.2.4"] 14 | exclude: 15 | - elixir: "1.15.8" 16 | otp: "27.2.4" 17 | - elixir: "1.16.3" 18 | otp: "27.2.4" 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: erlef/setup-beam@v1 22 | with: 23 | otp-version: ${{matrix.otp}} 24 | elixir-version: ${{matrix.elixir}} 25 | - run: mix deps.get 26 | - run: mix compile --warnings-as-errors 27 | - run: mix test 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | formatter-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.18.2-otp-27 2 | erlang 27.2.3 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | **Note** Styler's only public API is its usage as a formatter plugin. While you're welcome to play with its internals, 4 | they can and will change without that change being reflected in Styler's semantic version. 5 | 6 | ## main 7 | 8 | ## 1.4.2 9 | 10 | ### Fixes 11 | 12 | - fix comment misplacement for large comment blocks in config files and `# styler:sort` (#230, h/t @cschmatzler) 13 | 14 | ## 1.4.1 15 | 16 | ### Improvements 17 | 18 | - `to_timeout/1` rewrites to use the next largest unit in some simple instances 19 | 20 | ```elixir 21 | # before 22 | to_timeout(second: 60 * m) 23 | to_timeout(day: 7) 24 | # after 25 | to_timeout(minute: m) 26 | to_timeout(week: 1) 27 | ``` 28 | 29 | ### Fixes 30 | 31 | - fixed styler raising when encountering invalid function definition ast 32 | 33 | ## 1.4.0 34 | 35 | - A very nice change in alias lifting means Styler will make sure that your code is _using_ the aliases that it's specified. 36 | - Shoutout to the smartrent folks for finding pipifying recursion issues 37 | - Elixir 1.17 improvements and fixes 38 | - Elixir 1.19-dev: delete struct updates 39 | 40 | Read on for details. 41 | 42 | ### Improvements 43 | 44 | #### Alias Lifting 45 | 46 | This release taught Styler to try just that little bit harder when doing alias lifting. 47 | 48 | - general improvements around conflict detection, lifting in more correct places and fewer incorrect places (#193, h/t @jsw800) 49 | - use knowledge of existing aliases to shorten invocations (#201, h/t me) 50 | 51 | example: 52 | 53 | alias A.B.C 54 | 55 | A.B.C.foo() 56 | A.B.C.bar() 57 | A.B.C.baz() 58 | 59 | becomes: 60 | 61 | alias A.B.C 62 | 63 | C.foo() 64 | C.bar() 65 | C.baz() 66 | 67 | #### Struct Updates => Map Updates 68 | 69 | 1.19 deprecates struct update syntax in favor of map update syntax. 70 | 71 | ```elixir 72 | # This 73 | %Struct{x | y} 74 | # Styles to this 75 | %{x | y} 76 | ``` 77 | 78 | **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) 79 | 80 | #### Ex1.17+ 81 | 82 | - 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+) 83 | 84 | ### Fixes 85 | 86 | - `pipes`: handle pipifying when the first arg is itself a pipe: `c(a |> b, d)` => `a |> b() |> c(d)` (#214, h/t @kybishop) 87 | - `pipes`: handle pipifying nested functions `d(c(a |> b))` => `a |> b |> c() |> d` (#216, h/t @emkguts) 88 | - `with`: fix a stabby `with` `, else: (_ -> :ok)` being rewritten to a case (#219, h/t @iamhassangm) 89 | 90 | ## 1.3.3 91 | 92 | ### Improvements 93 | 94 | - `with do: body` and variations with no arrows in the head will be rewritten to just `body` 95 | - `# styler:sort` will sort arbitrary ast nodes within a `do end` block: 96 | 97 | Given: 98 | 99 | # styler:sort 100 | my_macro "some arg" do 101 | another_macro :q 102 | another_macro :w 103 | another_macro :e 104 | another_macro :r 105 | another_macro :t 106 | another_macro :y 107 | end 108 | 109 | We get 110 | 111 | # styler:sort 112 | my_macro "some arg" do 113 | another_macro :e 114 | another_macro :q 115 | another_macro :r 116 | another_macro :t 117 | another_macro :w 118 | another_macro :y 119 | end 120 | 121 | ### Fixes 122 | 123 | - fix a bug in comment-movement when multiple `# styler:sort` directives are added to a file at the same time 124 | 125 | ## 1.3.2 126 | 127 | ### Improvements 128 | 129 | - `# 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) 130 | 131 | ## 1.3.1 132 | 133 | ### Improvements 134 | 135 | - `# styler:sort` now works with maps and the `defstruct` macro 136 | 137 | ### Fixes 138 | 139 | - `# styler:sort` no longer blows up on keyword lists :X 140 | 141 | ## 1.3.0 142 | 143 | ### Improvements 144 | 145 | #### `# styler:sort` Styler's first comment directive 146 | 147 | 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. 148 | 149 | 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. 150 | 151 | To use the new directive, put it on the line before a list or wordlist. 152 | 153 | This example: 154 | 155 | ```elixir 156 | # styler:sort 157 | [:c, :a, :b] 158 | 159 | # styler:sort 160 | ~w(a list of words) 161 | 162 | # styler:sort 163 | @country_codes ~w( 164 | en_US 165 | po_PO 166 | fr_CA 167 | ja_JP 168 | ) 169 | 170 | # styler:sort 171 | a_var = 172 | [ 173 | Modules, 174 | In, 175 | A, 176 | List 177 | ] 178 | ``` 179 | 180 | Would yield: 181 | 182 | ```elixir 183 | # styler:sort 184 | [:a, :b, :c] 185 | 186 | # styler:sort 187 | ~w(a list of words) 188 | 189 | # styler:sort 190 | @country_codes ~w( 191 | en_US 192 | fr_CA 193 | ja_JP 194 | po_PO 195 | ) 196 | 197 | # styler:sort 198 | a_var = 199 | [ 200 | A, 201 | In, 202 | List, 203 | Modules 204 | ] 205 | ``` 206 | 207 | ## 1.2.1 208 | 209 | ### Fixes 210 | 211 | * `|>` 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) 212 | 213 | ## 1.2.0 214 | 215 | ### Improvements 216 | 217 | * `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) 218 | * `pipes`: unpiping assignments will make the assignment one-line when possible (Closes #181) 219 | * `deprecations`: 1.18 deprecations 220 | * `List.zip` => `Enum.zip` 221 | * `first..last = range` => `first..last//_ = range` 222 | 223 | ### Fixes 224 | 225 | * `pipes`: optimizations are less likely to move comments (Closes #176) 226 | 227 | ## 1.1.2 228 | 229 | ### Improvements 230 | 231 | * Config Sorting: improve comment handling when only sorting a few nodes (Closes #187) 232 | 233 | ## 1.1.1 234 | 235 | ### Improvements 236 | 237 | * `unless`: rewrite `unless a |> b |> c` as `unless !(a |> b() |> c())` rather than `unless a |> b() |> c() |> Kernel.!()` (h/t @gregmefford) 238 | 239 | ## 1.1.0 240 | 241 | ### Improvements 242 | 243 | 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. 244 | 245 | * `unless`: rewrite all `unless` to `if` (#190) 246 | * `pipes`: optimize `|> Stream.{each|map}(fun) |> Stream.run()` to `|> Enum.each(fun)` 247 | 248 | ### Fixes 249 | 250 | * `pipes`: optimizations reducing 2 pipes to 1 no longer squeeze all pipes onto one line (#180) 251 | * `if`: fix infinite loop rewriting negated if with empty do body `if x != y, do: (), else: :ok` (#196, h/t @itamm15) 252 | 253 | ## 1.0.0 254 | 255 | 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. 256 | 257 | In both cases, Styler is now smart enough to auto-apply the fixes we recommended in the old Readme. 258 | 259 | Other than that, a slew of powerful new features have been added, the neatest one (in the author's opinion anyways) being Alias Lifting. 260 | 261 | Thanks to everyone who reported bugs that contributed to all the fixes released in 1.0.0 as well. 262 | 263 | ### Improvements 264 | 265 | #### Alias Lifting 266 | 267 | 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. 268 | 269 | Put plainly, this code: 270 | 271 | ```elixir 272 | defmodule A do 273 | def lift_me() do 274 | A.B.C.foo() 275 | A.B.C.baz() 276 | end 277 | end 278 | ``` 279 | 280 | will become 281 | 282 | ```elixir 283 | defmodule A do 284 | @moduledoc false 285 | alias A.B.C 286 | 287 | def lift_me do 288 | C.foo() 289 | C.baz() 290 | end 291 | end 292 | ``` 293 | 294 | To exclude modules ending in `.Foo` from being lifted, add `styler: [alias_lifting_exclude: [Foo]]` to your `.formatter.exs` 295 | 296 | #### Module Attribute Lifting 297 | 298 | A long outstanding breakage of a first pass with Styler was breaking directives that relied on module attributes which Styler moved _after_ their uses. Styler now detects these potential breakages and automatically applies our suggested fix, which is creating a variable before the module. This usually happened when folks were using a library that autogenerated their moduledocs for them. 299 | 300 | In code, this module: 301 | 302 | ```elixir 303 | defmodule MyGreatLibrary do 304 | @library_options [...] 305 | @moduledoc make_pretty_docs(@library_options) 306 | use OptionsMagic, my_opts: @library_options 307 | 308 | ... 309 | end 310 | ``` 311 | 312 | Will now be styled like so: 313 | 314 | ```elixir 315 | library_options = [...] 316 | 317 | defmodule MyGreatLibrary do 318 | @moduledoc make_pretty_docs(library_options) 319 | use OptionsMagic, my_opts: unquote(library_options) 320 | 321 | @library_options library_options 322 | 323 | ... 324 | end 325 | ``` 326 | 327 | #### Mix Config File Organization 328 | 329 | Styler now organizes `Mix.Config.config/2,3` stanzas according to erlang term sorting. This helps manage large configuration files, removing the "where should I put this" burden from developers AND helping find duplicated configuration stanzas. 330 | 331 | See the moduledoc for `Styler.Style.Configs` for more. 332 | 333 | #### Pipe Optimizations 334 | 335 | * `Enum.into(x, [])` => `Enum.to_list(x)` 336 | * `Enum.into(x, [], mapper)` => `Enum.map(x, mapper)` 337 | * `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` 338 | * `lhs |> Enum.reverse() |> Kernel.++(enum)` => `lhs |> Enum.reverse(enum)` 339 | 340 | #### `with` styles 341 | 342 | * remove `with` structure with no left arrows in its head to be normal code (#174) 343 | * `with true <- x(), do: y` => `if x(), do: y` (#173) 344 | 345 | #### Everything Else 346 | 347 | * `if`/`unless`: invert if and unless with `!=` or `!==`, like we do for `!` and `not` #132 348 | * `@derive`: move `@derive` before `defstruct|schema|embedded_schema` declarations (fixes compiler warning!) #134 349 | * strings: rewrite double-quoted strings to use `~s` when there's 4+ escaped double-quotes 350 | (`"\"\"\"\""` -> `~s("""")`) (`Credo.Check.Readability.StringSigils`) #146 351 | * `Map.drop(foo, [single_key])` => `Map.delete(foo, single_key)` #161 (also in pipes) 352 | * `Keyword.drop(foo, [single_key])` => `Keyword.delete(foo, single_key)` #161 (also in pipes) 353 | 354 | ### Fixes 355 | 356 | * don't blow up on `def function_head_with_no_body_nor_parens` (#185, h/t @ypconstante) 357 | * fix `with` arrow replacement + redundant body removal creating invalid statements (#184, h/t @JesseHerrick) 358 | * allow Kernel unary `!` and `not` as valid pipe starts (#183, h/t @nherzing) 359 | * fix `Map.drop(x, [a | b])` registering as a chance to refactor to `Map.delete` 360 | * `alias`: expands aliases when moving an alias after another directive that relied on it (#137) 361 | * module directives: various fixes for unreported obscure crashes 362 | * pipes: fix a comment-shifting scenario when unpiping 363 | * `Timex.now/1` will no longer be rewritten to `DateTime.now!/1` due to Timex accepting a wider domain of "timezones" than the stdlib (#145, h/t @ivymarkwell) 364 | * `with`: skip nodes which (unexpectedly) do not contain a `do` body (#158, h/t @DavidB59) 365 | * `then(&fun/1)`: fix false positives on arithmetic `&1 + x / 1` (#164, h/t @aenglisc) 366 | 367 | ### Breaking Changes 368 | 369 | * drop support for elixir `1.14` 370 | * 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. 371 | * sorting configs for the first time can change your configuration; see [Mix Configs docs](docs/mix_configs.md) for more 372 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Hex.pm](https://img.shields.io/hexpm/v/styler)](https://hex.pm/packages/styler) 2 | [![Hexdocs.pm](https://img.shields.io/badge/docs-hexdocs.pm-purple)](https://hexdocs.pm/styler) 3 | [![Github.com](https://github.com/adobe/elixir-styler/actions/workflows/ci.yml/badge.svg)](https://github.com/adobe/elixir-styler/actions) 4 | 5 | # Styler 6 | 7 | Styler is an Elixir formatter plugin that's combination of `mix format` and `mix credo`, except instead of telling 8 | you what's wrong, it just rewrites the code for you to fit its style rules. 9 | 10 | 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) 11 | 12 | ## Features 13 | 14 | Styler fixes a plethora of elixir style and optimization issues automatically as part of mix format. 15 | 16 | [See Styler's documentation on Hex](https://hexdocs.pm/styler/styles.html) for the comprehensive list of its features. 17 | 18 | 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: 19 | 20 | - sorts and organizes `import`/`alias`/`require` and other [module directives](docs/module_directives.md) 21 | - keeps lists, sigils, and even arbitrary code sorted with the `# styler:sort` [comment directive](docs/comment_directives.md) 22 | - automatically creates aliases for repeatedly referenced modules names ([_"alias lifting"_](docs/module_directives.md#alias-lifting)) 23 | - optimizes pipe chains for [readability and performance](docs/pipes.md) 24 | - rewrites strings as sigils when it results in fewer escapes 25 | - auto-fixes [many credo rules](docs/credo.md), meaning you can spend less time fighting with CI 26 | 27 | ## Who is Styler for? 28 | 29 | 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. 30 | 31 | 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. 32 | 33 | Conversely, Styler probably _isn't_ a good match for: 34 | 35 | - experimental, macro-heavy codebases 36 | - teams that don't care about code standards 37 | 38 | ## Installation 39 | 40 | Add `:styler` as a dependency to your project's `mix.exs`: 41 | 42 | ```elixir 43 | def deps do 44 | [ 45 | {:styler, "~> 1.4", only: [:dev, :test], runtime: false}, 46 | ] 47 | end 48 | ``` 49 | 50 | Then add `Styler` as a plugin to your `.formatter.exs` file 51 | 52 | ```elixir 53 | [ 54 | plugins: [Styler] 55 | ] 56 | ``` 57 | 58 | And that's it! Now when you run `mix format` you'll also get the benefits of Styler's Stylish Stylings. 59 | 60 | **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. 61 | 62 | ### Configuration 63 | 64 | Styler can be configured in your `.formatter.exs` file 65 | 66 | ```elixir 67 | [ 68 | plugins: [Styler], 69 | styler: [ 70 | alias_lifting_exclude: [...] 71 | ] 72 | ] 73 | ``` 74 | 75 | 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. 76 | 77 | #### No Credo-Style Enable/Disable 78 | 79 | Styler [will not add configuration](https://github.com/adobe/elixir-styler/pull/127#issuecomment-1912242143) for ad-hoc enabling/disabling of rewrites. Sorry! 80 | 81 | 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! 82 | 83 | 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. 84 | 85 | ## WARNING: Styler can change the behaviour of your program! 86 | 87 | In some cases, this can introduce bugs. It goes without saying, but look over your changes before committing to main :) 88 | 89 | A simple example of a way Styler changes the behaviour of code is the following rewrite: 90 | 91 | ```elixir 92 | # Before: this case statement... 93 | case foo do 94 | true -> :ok 95 | false -> :error 96 | end 97 | 98 | # After: ... is rewritten by Styler to be an if statement!. 99 | if foo do 100 | :ok 101 | else 102 | :error 103 | end 104 | ``` 105 | 106 | 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. 107 | 108 | 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: 109 | 110 | ```elixir 111 | case foo do 112 | true -> :ok 113 | false -> :error 114 | other -> raise "expected `true` or `false`, got: #{inspect other}" 115 | end 116 | ``` 117 | 118 | 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. 119 | 120 | Other ways Styler can change your program: 121 | 122 | - [`with` statement rewrites](https://github.com/adobe/elixir-styler/issues/186) 123 | - [config file sorting](https://hexdocs.pm/styler/mix_configs.html#this-can-break-your-program) 124 | - and likely other ways. stay safe out there! 125 | 126 | ## Thanks & Inspiration 127 | 128 | ### [Sourceror](https://github.com/doorgan/sourceror/) 129 | 130 | Styler's first incarnation was as one-off scripts to rewrite an internal codebase to allow Credo rules to be turned on. 131 | 132 | These rewrites were entirely powered by the terrific `Sourceror` library. 133 | 134 | While `Styler` no longer relies on `Sourceror`, we're grateful for its author's help with those scripts, the inspiration 135 | Sourceror provided in showing us what was possible, and the changes to the Elixir AST APIs that it drove. 136 | 137 | Styler's [AST-Zipper](`m:Styler.Zipper`) implementation in this project was forked from Sourceror. Zipper has been a crucial 138 | part of our ability to ergonomically zip around (heh) Elixir AST. 139 | 140 | ### [Credo](https://github.com/rrrene/credo/) 141 | 142 | We never would've bothered trying to rewrite our codebase if we didn't have Credo rules we wanted to apply. 143 | 144 | Credo's tests and implementations were referenced for implementing Styles that took the work the rest of the way. 145 | 146 | Thanks to Credo & the Elixir community at large for coalescing around many of these Elixir style credos. 147 | -------------------------------------------------------------------------------- /docs/comment_directives.md: -------------------------------------------------------------------------------- 1 | ## Comment Directives 2 | 3 | Comment Directives are a Styler feature that let you instruct Styler to do maintain additional formatting via comments. 4 | 5 | The plural in the name is optimistic as there's currently only one, but who knows 6 | 7 | ### `# styler:sort` 8 | 9 | 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` 10 | 11 | Sorting is done via string comparison of the code. 12 | 13 | Styler knows how to sort the following things: 14 | 15 | - lists of elements 16 | - arbitrary code within `do end` blocks (helpful for schema-like macros) 17 | - `~w` sigils elements 18 | - keyword shapes (structs, maps, and keywords) 19 | 20 | Since you can't have comments in arbitrary places when using Elixir's formatter, 21 | Styler will apply those sorts when they're on the righthand side fo the following operators: 22 | 23 | - module directives (eg `@my_dir ~w(a list of things)`) 24 | - assignments (eg `x = ~w(a list again)`) 25 | - `defstruct` 26 | 27 | #### Examples 28 | 29 | **Before** 30 | 31 | ```elixir 32 | # styler:sort 33 | [:c, :a, :b] 34 | 35 | # styler:sort 36 | ~w(a list of words) 37 | 38 | # styler:sort 39 | @country_codes ~w( 40 | en_US 41 | po_PO 42 | fr_CA 43 | ja_JP 44 | ) 45 | 46 | # styler:sort 47 | a_var = 48 | [ 49 | Modules, 50 | In, 51 | A, 52 | List 53 | ] 54 | 55 | # styler:sort 56 | my_macro "some arg" do 57 | another_macro :q 58 | another_macro :w 59 | another_macro :e 60 | another_macro :r 61 | another_macro :t 62 | another_macro :y 63 | end 64 | ``` 65 | 66 | **After** 67 | 68 | ```elixir 69 | # styler:sort 70 | [:a, :b, :c] 71 | 72 | # styler:sort 73 | ~w(a list of words) 74 | 75 | # styler:sort 76 | @country_codes ~w( 77 | en_US 78 | fr_CA 79 | ja_JP 80 | po_PO 81 | ) 82 | 83 | # styler:sort 84 | a_var = 85 | [ 86 | A, 87 | In, 88 | List, 89 | Modules 90 | ] 91 | 92 | # styler:sort 93 | my_macro "some arg" do 94 | another_macro :e 95 | another_macro :q 96 | another_macro :r 97 | another_macro :t 98 | another_macro :w 99 | another_macro :y 100 | end 101 | ``` 102 | -------------------------------------------------------------------------------- /docs/control_flow_macros.md: -------------------------------------------------------------------------------- 1 | # Control Flow Macros (`case`, `if`, `unless`, `cond`, `with`) 2 | 3 | Elixir's Kernel documentation refers to these structures as "macros for control-flow". 4 | We often refer to them as "blocks" in our changelog, which is a much worse name, to be sure. 5 | 6 | You're likely here just to see what Styler does, in which case, please [click here to skip](#if-and-unless) the following manifesto on our philosophy regarding the usage of these macros. 7 | 8 | ## Which Control Flow Macro Should I Use? 9 | 10 | The number of "blocks" in Elixir means there are many ways to write semantically equivalent code, often leaving developers [in the dark as to which structure they should use.](https://www.reddit.com/r/elixir/comments/1ctbtcl/i_am_completely_lost_when_it_comes_to_which/) 11 | 12 | We believe readability is enhanced by using the simplest api possible, whether we're talking about internal module function calls or standard-library macros. 13 | 14 | ### use `case`, `if`, or `cond` when... 15 | 16 | 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. 17 | 18 | (`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`). 19 | 20 | ### use `unless` when... 21 | 22 | Never! `unless` [is being deprecated](https://github.com/elixir-lang/elixir/pull/13769#issuecomment-2334878315) and so should not be used. 23 | 24 | ### use `with` when... 25 | 26 | > `with` great power comes great responsibility 27 | > 28 | > - Uncle Ben 29 | 30 | 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). 31 | 32 | 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. 33 | 34 | Thus, `with` is the control-flow structure of last resort. We advocate that `with` **should only be used when more basic expressions do not suffice or become overly verbose**. As for verbosity, we subscribe to the [Chris Keathley school of thought](https://www.youtube.com/watch?v=l-8ghbdRB1w) that judicious nesting of control flow blocks within a function isn't evil and more-often-than-not is superior to spreading implementation over many small single-use functions. We'd even go so far as to suggest that cyclomatic complexity is an inexact measure of code quality, with more than a few false negatives and many false positives. 35 | 36 | `with` is a great way to unnest multiple `case` statements when every failure branch of those statements results in the same error. This is easily and succinctly expressed with `with`'s `else` block: `else (_ -> :error)`. As Keathley says though, [Avoid Else In With Blocks](https://keathley.io/blog/good-and-bad-elixir.html#avoid-else-in-with-blocks). Having multiple else clauses "means that the error conditions matter. Which means that you don’t want `with` at all. You want `case`." 37 | 38 | It's acceptable to use one-line `with` statements (eg `with {:ok, _} <- Repo.update(changeset), do: :ok`) to signify that other branches are uninteresting or unmodified by your code, but ultimately that can hide the possible returns of a function from the reader, making it more onerous to debug all possible branches of the code in their mental model of the function. In other words, ideally all function calls in a `with` statement head have obvious error types for the reader, leaving their omission in the code acceptable as the reader feels no need to investigate further. The example at the start of this paragraph with an `Ecto.Repo` call is a good example, as most developers in a codebase using Ecto are expected to be familiar with its basic API. 39 | 40 | Using `case` rather than `with` for branches with unusual failure types can help document code as well as save the reader time in tracking down types. For example, replacing the following with a `with` statement that only matched against the `{:ok, _}` tuple would hide from readers that an atypically-shaped 3-tuple is returned when things go wrong. 41 | 42 | ```elixir 43 | case some_http_call() do 44 | {:ok, _response} -> :ok 45 | {:error, http_error, response} -> {:error, http_error, response} 46 | end 47 | ``` 48 | 49 | ## `if` and `unless` 50 | 51 | Styler removes `else: nil` clauses: 52 | 53 | ```elixir 54 | if a, do: b, else: nil 55 | # styled: 56 | if a, do: b 57 | ``` 58 | 59 | ### Negation Inversion 60 | 61 | Styler removes negators in the head of `if` and `unless` statements by "inverting" the statement. 62 | The following operators are considered "negators": `!`, `not`, `!=`, `!==` 63 | 64 | 65 | Examples: 66 | 67 | ```elixir 68 | # negated `if` statement with no `else` clause are rewritten to `unless` 69 | if not x, do: y 70 | # Styled: 71 | unless x, do: y 72 | 73 | # negated `if` statements with an `else` clause have their clauses inverted and negation removed 74 | if !x, do: y, else: z 75 | # Styled: 76 | if x, do: z, else: y 77 | 78 | # negated `unless` statements are rewritten to `if` 79 | unless x != y, do: z 80 | # B styled: 81 | if x == y, do: z 82 | 83 | # `unless` with `else` is verboten; these are always rewritten to `if` statements 84 | unless x, do: y, else: z 85 | # styled: 86 | if x, do: z, else: y 87 | ``` 88 | 89 | Because elixir relies on truthy/falsey values for its `if` statements, boolean casting is unnecessary and so double negation is simply removed. 90 | 91 | ```elixir 92 | if !!x, do: y 93 | # styled: 94 | if x, do: y 95 | ``` 96 | 97 | ## `case` 98 | 99 | ### "Erlang heritage" `case` true/false -> `if` 100 | 101 | 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. 102 | 103 | In other words, Styler leaves the code with better style, trumping obscure exception design :) 104 | 105 | ```elixir 106 | # Styler will rewrite this even if the clause order is flipped, 107 | # and if the `false` is replaced with a wildcard (`_`) 108 | case foo do 109 | true -> :ok 110 | false -> :error 111 | end 112 | 113 | # styled: 114 | if foo do 115 | :ok 116 | else 117 | :error 118 | end 119 | ``` 120 | 121 | Per the argument above, if the `if` statement is an incorrect rewrite for your program, we recommend this manual fix rewrite: 122 | 123 | ```elixir 124 | case foo do 125 | true -> :ok 126 | false -> :error 127 | other -> raise "expected `true` or `false`, got: #{inspect other}" 128 | end 129 | ``` 130 | 131 | ## `cond` 132 | 133 | Styler has only one `cond` statement rewrite: replace 2-clause statements with `if` statements. 134 | 135 | ```elixir 136 | # Given 137 | cond do 138 | a -> b 139 | true -> c 140 | end 141 | # Styled 142 | if a do 143 | b 144 | else 145 | c 146 | end 147 | ``` 148 | 149 | ## `with` 150 | 151 | `with` statements are extremely expressive. Styler tries to remove any unnecessary complexity from them in the following ways. 152 | 153 | ### Remove Identity Else Clause 154 | 155 | Like if statements with `nil` as their else clause, the identity `else` clause is the default for `with` statements and so is removed. 156 | 157 | ```elixir 158 | # Given 159 | with :ok <- b(), :ok <- b() do 160 | foo() 161 | else 162 | error -> error 163 | end 164 | # Styled: 165 | with :ok <- b(), :ok <- b() do 166 | foo() 167 | end 168 | ``` 169 | 170 | ### Remove The Statement Entirely 171 | 172 | While you might think "surely this kind of code never appears in the wild", it absolutely does. Typically it's the result of someone refactoring a pattern away and not looking at the larger picture and realizing that the with statement now serves no purpose. 173 | 174 | Maybe someday the compiler will warn about these use cases. Until then, Styler to the rescue. 175 | 176 | ```elixir 177 | # Given: 178 | with a <- b(), 179 | c <- d(), 180 | e <- f(), 181 | do: g, 182 | else: (_ -> h) 183 | # Styled: 184 | a = b() 185 | c = d() 186 | e = f() 187 | g 188 | 189 | # Given 190 | with value <- arg do 191 | value 192 | end 193 | # Styled: 194 | arg 195 | ``` 196 | 197 | ### Replace `_ <- rhs` with `rhs` 198 | 199 | This is another case of "less is more" for the reader. 200 | 201 | ```elixir 202 | # Given 203 | with :ok <- x, 204 | _ <- y(), 205 | {:ok, _} <- z do 206 | :ok 207 | end 208 | # Styled: 209 | with :ok <- x, 210 | y(), 211 | {:ok, _} <- z do 212 | :ok 213 | end 214 | ``` 215 | 216 | ### Replace non-branching `bar <-` with `bar =` 217 | 218 | `<-` is for branching. If the lefthand side is the trivial match (a bare variable), Styler rewrites it to use the `=` operator instead. 219 | 220 | ```elixir 221 | # Given 222 | with :ok <- foo(), 223 | bar <- baz(), 224 | :ok <- woo(), 225 | do: {:ok, bar} 226 | # Styled 227 | with :ok <- foo(), 228 | bar = baz(), 229 | :ok <- woo(), 230 | do: {:ok, bar} 231 | ``` 232 | 233 | ### Move assignments from `with` statement head 234 | 235 | Just because any program _could_ be written entirely within the head of a `with` statement doesn't mean it should be! 236 | 237 | Styler moves assignments that aren't trapped between `<-` outside of the head. Combined with the non-pattern-matching replacement above, we get the following: 238 | 239 | ```elixir 240 | # Given 241 | with foo <- bar, 242 | x = y, 243 | :ok <- baz, 244 | bop <- boop, 245 | :ok <- blop, 246 | foo <- bar, 247 | :success = hope_this_works! do 248 | :ok 249 | end 250 | # Styled: 251 | foo = bar 252 | x = y 253 | 254 | with :ok <- baz, 255 | bop = boop, 256 | :ok <- blop do 257 | foo = bar 258 | :success = hope_this_works! 259 | :ok 260 | end 261 | ``` 262 | 263 | ### Remove redundant final clause 264 | 265 | If the pattern of the final clause of the head is also the `with` statements `do` body, styler nixes the final match and makes the right hand side of the clause into the do body. 266 | 267 | ```elixir 268 | # Given 269 | with {:ok, a} <- foo(), 270 | {:ok, b} <- bar(a) do 271 | {:ok, b} 272 | end 273 | # Styled: 274 | with {:ok, a} <- foo() do 275 | bar(a) 276 | end 277 | ``` 278 | 279 | ### Replace with `case` 280 | 281 | A `with` statement with a single clause in the head and an `else` body is really just a `case` statement putting on airs. 282 | 283 | ```elixir 284 | # Given: 285 | with :ok <- foo do 286 | :success 287 | else 288 | :fail -> :failure 289 | error -> error 290 | end 291 | # Styled: 292 | case foo do 293 | :ok -> :success 294 | :fail -> :failure 295 | error -> error 296 | end 297 | ``` 298 | 299 | ### Replace with `if` 300 | 301 | Given Styler rewrites trivial `case` to `if`, it shouldn't be a surprise that that same rule means that `with` can be rewritten to `if` in some cases. 302 | 303 | ```elixir 304 | # Given: 305 | with true <- foo(), bar <- baz() do 306 | {:ok, bar} 307 | else 308 | _ -> :error 309 | end 310 | # Styled: 311 | if foo() do 312 | bar = baz() 313 | {:ok, bar} 314 | else 315 | :error 316 | end 317 | ``` 318 | -------------------------------------------------------------------------------- /docs/credo.md: -------------------------------------------------------------------------------- 1 | ### Credo Rules Styler Replaces 2 | 3 | If you're using Credo and Styler, **we recommend disabling these rules in `.credo.exs`** to save on unnecessary checks in CI. 4 | As long as you're running `mix format --check-formatted` in CI, Styler will be enforcing the rules for you, so checking them with Credo is redundant. 5 | 6 | Disabling the rules means updating your `.credo.exs` depending on your configuration: 7 | 8 | - if you're using `checks: %{enabled: [...]}`, ensure none of the checks are listed in your enabled checks 9 | - if you're using `checks: %{disabled: [...]}`, copy/paste the snippet below into the list 10 | - if you're using `checks: [...]`, copy/paste the snippet below into the list and ensure none of the checks appear earlier in the list 11 | 12 | ```elixir 13 | # Styler Rewrites 14 | # 15 | # The following rules are automatically rewritten by Styler and so disabled here to save time 16 | # Some of the rules have `priority: :high`, meaning Credo runs them unless we explicitly disable them 17 | # (removing them from this file wouldn't be enough, the `false` is required) 18 | # 19 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 20 | {Credo.Check.Consistency.ParameterPatternMatching, false}, 21 | {Credo.Check.Design.AliasUsage, false}, 22 | {Credo.Check.Readability.AliasOrder, false}, 23 | {Credo.Check.Readability.BlockPipe, false}, 24 | {Credo.Check.Readability.LargeNumbers, false}, 25 | {Credo.Check.Readability.ModuleDoc, false}, 26 | {Credo.Check.Readability.MultiAlias, false}, 27 | {Credo.Check.Readability.OneArityFunctionInPipe, false}, 28 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, false}, 29 | {Credo.Check.Readability.PipeIntoAnonymousFunctions, false}, 30 | {Credo.Check.Readability.PreferImplicitTry, false}, 31 | {Credo.Check.Readability.SinglePipe, false}, 32 | {Credo.Check.Readability.StrictModuleLayout, false}, 33 | {Credo.Check.Readability.StringSigils, false}, 34 | {Credo.Check.Readability.UnnecessaryAliasExpansion, false}, 35 | {Credo.Check.Readability.WithSingleClause, false}, 36 | {Credo.Check.Refactor.CaseTrivialMatches, false}, 37 | {Credo.Check.Refactor.CondStatements, false}, 38 | {Credo.Check.Refactor.FilterCount, false}, 39 | {Credo.Check.Refactor.MapInto, false}, 40 | {Credo.Check.Refactor.MapJoin, false}, 41 | {Credo.Check.Refactor.NegatedConditionsInUnless, false}, 42 | {Credo.Check.Refactor.NegatedConditionsWithElse, false}, 43 | {Credo.Check.Refactor.PipeChainStart, false}, 44 | {Credo.Check.Refactor.RedundantWithClauseResult, false}, 45 | {Credo.Check.Refactor.UnlessWithElse, false}, 46 | {Credo.Check.Refactor.WithClauses, false}, 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/deprecations.md: -------------------------------------------------------------------------------- 1 | ## Elixir Deprecation Rewrites 2 | 3 | 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! 4 | 5 | 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. 6 | 7 | ### elixir `main` 8 | 9 | https://github.com/elixir-lang/elixir/blob/main/CHANGELOG.md#4-hard-deprecations 10 | 11 | These deprecations will be released with Elixir 1.18 12 | 13 | #### `List.zip/1` 14 | 15 | ```elixir 16 | # Before 17 | List.zip(list) 18 | # Styled 19 | Enum.zip(list) 20 | ``` 21 | 22 | #### `unless` 23 | 24 | This is covered by the Elixir Formatter with the `--migrate` flag, but Styler brings the same transformation to codebases on earlier versions of Elixir. 25 | 26 | Rewrite `unless x` to `if !x` 27 | 28 | ### Change Struct Updates to Map Updates 29 | 30 | 1.19 deprecates struct update syntax in favor of map update syntax. 31 | 32 | ```elixir 33 | # This 34 | %Struct{x | y} 35 | # Styles to this 36 | %{x | y} 37 | ``` 38 | 39 | **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. 40 | 41 | ### 1.18 42 | 43 | None? 44 | 45 | ### 1.17 46 | 47 | [1.17 Deprecations](https://hexdocs.pm/elixir/1.17.0/changelog.html#4-hard-deprecations) 48 | 49 | - Replace `:timer.units(x)` with the new `to_timeout(unit: x)` for `hours|minutes|seconds` 50 | 51 | #### Range Matching Without Step 52 | 53 | ```elixir 54 | # Before 55 | first..last = range 56 | # Styled 57 | first..last//_ = range 58 | 59 | # Before 60 | def foo(x..y), do: :ok 61 | # Styled 62 | def foo(x..y//_), do: :ok 63 | ``` 64 | 65 | ### 1.16 66 | 67 | [1.16 Deprecations](https://hexdocs.pm/elixir/1.16.0/changelog.html#4-hard-deprecations) 68 | 69 | #### `File.stream!/3` `:line` and `:bytes` deprecation 70 | 71 | ```elixir 72 | # Before 73 | File.stream!(path, [encoding: :utf8, trim_bom: true], :line) 74 | # Styled 75 | File.stream!(path, :line, encoding: :utf8, trim_bom: true) 76 | ``` 77 | 78 | ### Explicit decreasing ranges 79 | 80 | 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. 81 | 82 | ```elixir 83 | # Before 84 | Enum.slice(x, 1..-2) 85 | # Styled 86 | Enum.slice(x, 1..-2//1) 87 | 88 | # Before 89 | Date.range(~D[2000-01-01], ~D[1999-01-01]) 90 | # Styled 91 | Date.range(~D[2000-01-01], ~D[1999-01-01], -1) 92 | ``` 93 | 94 | ### 1.15 95 | 96 | [1.15 Deprecations](https://hexdocs.pm/elixir/1.15.0/changelog.html#4-hard-deprecations) 97 | 98 | | Before | After | 99 | |--------|-------| 100 | | `Logger.warn` | `Logger.warning`| 101 | | `Path.safe_relative_to/2` | `Path.safe_relative/2`| 102 | | `~R/my_regex/` | `~r/my_regex/`| 103 | | `Date.range/2` with decreasing range | `Date.range/3` *| 104 | | `IO.read/bin_read` with `:all` option | replace `:all` with `:eof`| 105 | -------------------------------------------------------------------------------- /docs/mix_configs.md: -------------------------------------------------------------------------------- 1 | # Mix Configs 2 | 3 | Mix Config files have their config stanzas sorted. Similar to the sorting of aliases, this delivers consistency to an otherwise arbitrary world, and can even help catch bugs like configuring the same key multiple times. 4 | 5 | A file is considered a config file if 6 | 7 | 1. its path matches `~r|config/.*\.exs|` `~r|rel/overlays/.*\.exs|` 8 | 2. the file has `import Config` 9 | 10 | Once a file is detected as a mix config, its `config/2,3` stanzas are grouped and ordered like so: 11 | 12 | - group config stanzas separated by assignments (`x = y`) together 13 | - sort each group according to erlang term sorting 14 | - move all existing assignments between the config stanzas to above the stanzas (without changing their ordering) 15 | 16 | ## THIS CAN BREAK YOUR PROGRAM 17 | 18 | It's important to double check your configuration after running Styler on it for the first time. 19 | 20 | **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. 21 | 22 | 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: 23 | 24 | ```elixir 25 | string = "i am a string" 26 | atom = :i_am_an_atom 27 | 28 | config :my_app, value_must_be_an_atom: string 29 | ... 30 | ... 31 | config :my_app, value_must_be_an_atom: atom 32 | ``` 33 | 34 | 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) 35 | 36 | ```elixir 37 | string = "i am a string" 38 | atom = :i_am_an_atom 39 | 40 | # The value that must be an atom is now a string! 41 | config :my_app, value_must_be_an_atom: atom 42 | config :my_app, value_must_be_an_atom: string 43 | ``` 44 | 45 | ## Examples 46 | 47 | Sorts configs by erlang term ordering: 48 | 49 | ```elixir 50 | # Given 51 | import Config 52 | 53 | config :z, :x, :c 54 | config :a, :b, :c 55 | config :y, :x, :z 56 | config :a, :c, :d 57 | 58 | # Styled: 59 | import Config 60 | 61 | config :a, :b, :c 62 | config :a, :c, :d 63 | 64 | config :y, :x, :z 65 | 66 | config :z, :x, :c 67 | ``` 68 | 69 | Non-config statements break the file up into chunks, where each chunk is sorted separately relative to itself. 70 | 71 | ```elixir 72 | # Given 73 | import Config 74 | 75 | config :z, :x, :c 76 | config :a, :b, :c 77 | var = "value" 78 | config :y, :x, var 79 | config :a, :c, var 80 | 81 | # Styled: 82 | import Config 83 | 84 | config :a, :b, :c 85 | config :z, :x, :c 86 | 87 | var = "value" 88 | 89 | config :a, :c, var 90 | config :y, :x, var 91 | ``` 92 | -------------------------------------------------------------------------------- /docs/module_directives.md: -------------------------------------------------------------------------------- 1 | ## Adds Moduledoc 2 | 3 | Adds `@moduledoc false` to modules without a moduledoc unless the module's name ends with one of the following: 4 | 5 | * `Test` 6 | * `Mixfile` 7 | * `MixProject` 8 | * `Controller` 9 | * `Endpoint` 10 | * `Repo` 11 | * `Router` 12 | * `Socket` 13 | * `View` 14 | * `HTML` 15 | * `JSON` 16 | 17 | ## Directive Expansion 18 | 19 | Expands `Module.{SubmoduleA, SubmoduleB}` to their explicit forms for ease of searching. 20 | 21 | ```elixir 22 | # Before 23 | import Foo.{Bar, Baz, Bop} 24 | alias Foo.{Bar, Baz.A, Bop} 25 | 26 | # After 27 | import Foo.Bar 28 | import Foo.Baz 29 | import Foo.Bop 30 | 31 | alias Foo.Bar 32 | alias Foo.Baz.A 33 | alias Foo.Bop 34 | ``` 35 | 36 | ## Directive Organization 37 | 38 | Modules directives are sorted into the following order: 39 | 40 | * `@shortdoc` 41 | * `@moduledoc` (adds `@moduledoc false`) 42 | * `@behaviour` 43 | * `use` 44 | * `import` (sorted alphabetically) 45 | * `alias` (sorted alphabetically) 46 | * `require` (sorted alphabetically) 47 | * everything else (order unchanged) 48 | 49 | ### Before 50 | 51 | ```elixir 52 | defmodule Foo do 53 | @behaviour Lawful 54 | alias A.A 55 | require A 56 | 57 | use B 58 | 59 | def c(x), do: y 60 | 61 | import C 62 | @behaviour Chaotic 63 | @doc "d doc" 64 | def d do 65 | alias X.X 66 | alias H.H 67 | 68 | alias Z.Z 69 | import Ecto.Query 70 | X.foo() 71 | end 72 | @shortdoc "it's pretty short" 73 | import A 74 | alias C.C 75 | alias D.D 76 | 77 | require C 78 | require B 79 | 80 | use A 81 | 82 | alias C.C 83 | alias A.A 84 | 85 | @moduledoc "README.md" 86 | |> File.read!() 87 | |> String.split("") 88 | |> Enum.fetch!(1) 89 | end 90 | ``` 91 | 92 | ### After 93 | 94 | ```elixir 95 | defmodule Foo do 96 | @shortdoc "it's pretty short" 97 | @moduledoc "README.md" 98 | |> File.read!() 99 | |> String.split("") 100 | |> Enum.fetch!(1) 101 | @behaviour Chaotic 102 | @behaviour Lawful 103 | 104 | use B 105 | use A.A 106 | 107 | import A.A 108 | import C 109 | 110 | alias A.A 111 | alias C.C 112 | alias D.D 113 | 114 | require A 115 | require B 116 | require C 117 | 118 | def c(x), do: y 119 | 120 | @doc "d doc" 121 | def d do 122 | import Ecto.Query 123 | 124 | alias H.H 125 | alias X.X 126 | alias Z.Z 127 | 128 | X.foo() 129 | end 130 | end 131 | ``` 132 | 133 | If any line previously relied on an alias, the alias is fully expanded when it is moved above the alias: 134 | 135 | ```elixir 136 | # Given 137 | alias Foo.Bar 138 | import Bar 139 | # Styled 140 | import Foo.Bar 141 | 142 | alias Foo.Bar 143 | ``` 144 | 145 | ## Alias Lifting 146 | 147 | When a module with three parts is referenced two or more times, styler creates a new alias for that module and uses it. 148 | 149 | ```elixir 150 | # Given 151 | require A.B.C 152 | 153 | A.B.C.foo() 154 | A.B.C.bar() 155 | 156 | # Styled 157 | alias A.B.C 158 | 159 | require C 160 | 161 | C.foo() 162 | C.bar() 163 | ``` 164 | 165 | Styler also notices when you have a module aliased and aren't employing that alias and will do the updates for you. 166 | 167 | ```elixir 168 | # Given 169 | alias My.Apps.Widget 170 | 171 | x = Repo.get(My.Apps.Widget, id) 172 | 173 | # Styled 174 | alias My.Apps.Widget 175 | 176 | x = Repo.get(Widget, id) 177 | ``` 178 | 179 | ### Collisions 180 | 181 | 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. 182 | 183 | You can specify additional modules to exclude from lifting via the `:alias_lifting_exclude` configuration option. For the example above, the following configuration would keep Styler from creating the `alias A.B.C` node: 184 | 185 | ```elixir 186 | # .formatter.exs 187 | [ 188 | plugins: [Styler], 189 | styler: [alias_lifting_exclude: [:C]], 190 | ] 191 | ``` 192 | -------------------------------------------------------------------------------- /docs/pipes.md: -------------------------------------------------------------------------------- 1 | ## Pipe Chains 2 | 3 | ### Pipe Start 4 | 5 | Styler will ensure that the start of a pipechain is a 0-arity function, a raw value, or a variable. 6 | 7 | ```elixir 8 | Enum.at(enum, 5) 9 | |> IO.inspect() 10 | 11 | # Styled: 12 | enum 13 | |> Enum.at(5) 14 | |> IO.inspect() 15 | ``` 16 | 17 | 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. 18 | 19 | ```elixir 20 | if a do 21 | b 22 | else 23 | c 24 | end 25 | |> Enum.at(4) 26 | |> IO.inspect() 27 | 28 | # Styled: 29 | if_result = 30 | if a do 31 | b 32 | else 33 | c 34 | end 35 | 36 | if_result 37 | |> Enum.at(4) 38 | |> IO.inspect() 39 | ``` 40 | 41 | ### Add parenthesis to function calls in pipes 42 | 43 | ```elixir 44 | a |> b |> c |> d 45 | # Styled: 46 | a |> b() |> c() |> d() 47 | ``` 48 | 49 | ### Remove Unnecessary `then/2` 50 | 51 | When the piped argument is being passed as the first argument to the inner function, there's no need for `then/2`. 52 | 53 | ```elixir 54 | a |> then(&f(&1, ...)) |> b() 55 | # Styled: 56 | a |> f(...) |> b() 57 | ``` 58 | 59 | - add parens to function calls `|> fun |>` => `|> fun() |>` 60 | 61 | ### Add `then/2` when defining and calling anonymous functions in pipes 62 | 63 | ```elixir 64 | a |> (fn x -> x end).() |> c() 65 | # Styled: 66 | a |> then(fn x -> x end) |> c() 67 | ``` 68 | 69 | ### Piped function optimizations 70 | 71 | Two function calls into one! Fewer steps is always nice. 72 | 73 | ```elixir 74 | # reverse |> concat => reverse/2 75 | a |> Enum.reverse() |> Enum.concat(enum) |> ... 76 | # Styled: 77 | a |> Enum.reverse(enum) |> ... 78 | 79 | # filter |> count => count(filter) 80 | a |> Enum.filter(filterer) |> Enum.count() |> ... 81 | # Styled: 82 | a |> Enum.count(filterer) |> ... 83 | 84 | # map |> join => map_join 85 | a |> Enum.map(mapper) |> Enum.join(joiner) |> ... 86 | # Styled: 87 | a |> Enum.map_join(joiner, mapper) |> ... 88 | 89 | # Enum.map |> X.new() => X.new(mapper) 90 | # where X is one of: Map, MapSet, Keyword 91 | a |> Enum.map(mapper) |> Map.new() |> ... 92 | # Styled: 93 | a |> Map.new(mapper) |> ... 94 | 95 | # Enum.map |> Enum.into(empty_collectable) => X.new(mapper) 96 | # Where empty_collectable is one of `%{}`, `Map.new()`, `Keyword.new()`, `MapSet.new()` 97 | # Given: 98 | a |> Enum.map(mapper) |> Enum.into(%{}) |> ... 99 | # Styled: 100 | a |> Map.new(mapper) |> ... 101 | 102 | # Given: 103 | a |> b() |> Stream.each(fun) |> Stream.run() 104 | a |> b() |> Stream.map(fun) |> Stream.run() 105 | # Styled: 106 | a |> b() |> Enum.each(fun) 107 | a |> b() |> Enum.each(fun) 108 | ``` 109 | 110 | ### Unpiping Single Pipes 111 | 112 | 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. 113 | 114 | ```elixir 115 | foo = bar |> baz() 116 | # Styled: 117 | foo = baz(bar) 118 | 119 | map = a |> Enum.map(mapper) |> Map.new() 120 | # Styled: 121 | map = Map.new(a, mapper) 122 | ``` 123 | 124 | ### Pipe-ify 125 | 126 | If the first argument to a function call is a pipe, Styler makes the function call the final pipe of the chain. 127 | 128 | ```elixir 129 | d(a |> b |> c) 130 | # Styled 131 | a |> b() |> c() |> d() 132 | ``` 133 | -------------------------------------------------------------------------------- /docs/styles.md: -------------------------------------------------------------------------------- 1 | # Simple (Single Node) Styles 2 | 3 | Function Performance & Readability Optimizations 4 | 5 | Optimizing for either performance or readability, probably both! 6 | These apply to the piped versions as well 7 | 8 | ## Strings to Sigils 9 | 10 | Rewrites strings with 4 or more escaped quotes to string sigils with an alternative delimiter. 11 | The delimiter will be one of `" ( { | [ ' < /`, chosen by which would require the fewest escapes, and otherwise preferred in the order listed. 12 | 13 | ```elixir 14 | # Before 15 | "{\"errors\":[\"Not Authorized\"]}" 16 | # Styled 17 | ~s({"errors":["Not Authorized"]}) 18 | ``` 19 | 20 | ## Large Base 10 Numbers 21 | 22 | Style base 10 numbers with 5 or more digits to have a `_` every three digits. 23 | Formatter already does this except it doesn't rewrite "typos" like `100_000_0`. 24 | 25 | If you're concerned that this breaks your team's formatting for things like "cents" (like "$100" being written as `100_00`), 26 | consider using a library made for denoting currencies rather than raw elixir integers. 27 | 28 | | Before | After | 29 | |--------|-------| 30 | | `10000 ` | `10_000`| 31 | | `1_0_0_0_0` | `10_000` (elixir's formatter leaves the former as-is)| 32 | | `-543213 ` | `-543_213`| 33 | | `123456789 ` | `123_456_789`| 34 | | `55333.22 ` | `55_333.22`| 35 | | `-123456728.0001 ` | `-123_456_728.0001`| 36 | 37 | ## `Enum.into` -> `X.new` 38 | 39 | This rewrite is applied when the collectable is a new map, keyword list, or mapset via `Enum.into/2,3`. 40 | 41 | 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" 42 | 43 | Note that all of the examples below also apply to pipes (`enum |> Enum.into(...)`) 44 | 45 | | Before | After | 46 | |--------|-------| 47 | | `Enum.into(enum, %{})` | `Map.new(enum)`| 48 | | `Enum.into(enum, Map.new())` | `Map.new(enum)`| 49 | | `Enum.into(enum, Keyword.new())` | `Keyword.new(enum)`| 50 | | `Enum.into(enum, MapSet.new())` | `MapSet.new(enum)`| 51 | | `Enum.into(enum, %{}, fn x -> {x, x} end)` | `Map.new(enum, fn x -> {x, x} end)`| 52 | | `Enum.into(enum, [])` | `Enum.to_list(enum)` | 53 | | `Enum.into(enum, [], mapper)` | `Enum.map(enum, mapper)`| 54 | 55 | ## Map/Keyword.merge w/ single key literal -> X.put 56 | 57 | `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. 58 | 59 | ```elixir 60 | # Before 61 | Keyword.merge(kw, [key: :value]) 62 | # Styled 63 | Keyword.put(kw, :key, :value) 64 | 65 | # Before 66 | Map.merge(map, %{key: :value}) 67 | # Styled 68 | Map.put(map, :key, :value) 69 | 70 | # Before 71 | Map.merge(map, %{key => value}) 72 | # Styled 73 | Map.put(map, key, value) 74 | 75 | # Before 76 | map |> Map.merge(%{key: value}) |> foo() 77 | # Styled 78 | map |> Map.put(:key, value) |> foo() 79 | ``` 80 | 81 | ## Map/Keyword.drop w/ single key -> X.delete 82 | 83 | 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` 84 | ```elixir 85 | # Before 86 | Map.drop(map, [key]) 87 | # Styled 88 | Map.delete(map, key) 89 | 90 | # Before 91 | Keyword.drop(kw, [key]) 92 | # Styled 93 | Keyword.delete(kw, key) 94 | ``` 95 | 96 | ## `Enum.reverse/1` and concatenation -> `Enum.reverse/2` 97 | 98 | `Enum.reverse/2` optimizes a two-step reverse and concatenation into a single step. 99 | 100 | ```elixir 101 | # Before 102 | Enum.reverse(foo) ++ bar 103 | # Styled 104 | Enum.reverse(foo, bar) 105 | 106 | # Before 107 | baz |> Enum.reverse() |> Enum.concat(bop) 108 | # Styled 109 | Enum.reverse(baz, bop) 110 | ``` 111 | 112 | ## `Timex.now/0` -> `DateTime.utc_now/0` 113 | 114 | Timex certainly has its uses, but knowing what stdlib date/time struct is returned by `now/0` is a bit difficult! 115 | 116 | We prefer calling the actual function rather than its rename in Timex, helping the reader by being more explicit. 117 | 118 | This also hews to our internal styleguide's "Don't make one-line helper functions" guidance. 119 | 120 | ## `DateModule.compare/2` -> `DateModule.[before?|after?]` 121 | 122 | Again, the goal is readability and maintainability. `before?/2` and `after?/2` were implemented long after `compare/2`, 123 | so it's not unusual that a codebase needs a lot of refactoring to be brought up to date with these new functions. 124 | That's where Styler comes in! 125 | 126 | The examples below use `DateTime.compare/2`, but the same is also done for `NaiveDateTime|Time|Date.compare/2` 127 | 128 | ```elixir 129 | # Before 130 | DateTime.compare(start, end_date) == :gt 131 | # Styled 132 | DateTime.after?(start, end_date) 133 | 134 | # Before 135 | DateTime.compare(start, end_date) == :lt 136 | # Styled 137 | DateTime.before?(start, end_date) 138 | ``` 139 | 140 | ## Implicit `try` 141 | 142 | 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` 143 | 144 | ```elixir 145 | # before 146 | def foo do 147 | try do 148 | throw_ball() 149 | catch 150 | :ball -> :caught 151 | end 152 | end 153 | 154 | # Styled: 155 | def foo do 156 | throw_ball() 157 | catch 158 | :ball -> :caught 159 | end 160 | ``` 161 | 162 | ## Remove parenthesis from 0-arity function & macro definitions 163 | 164 | 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. 165 | 166 | ```elixir 167 | # Before 168 | def foo() do 169 | defp foo() do 170 | defmacro foo() do 171 | defmacrop foo() do 172 | 173 | # Styled 174 | def foo do 175 | defp foo do 176 | defmacro foo do 177 | defmacrop foo do 178 | ``` 179 | 180 | ## Variable matching on the right 181 | 182 | ```elixir 183 | # Before 184 | case foo do 185 | bar = %{baz: baz? = true} -> :baz? 186 | opts = [[a = %{}] | _] -> a 187 | end 188 | # Styled: 189 | case foo do 190 | %{baz: true = baz?} = bar -> :baz? 191 | [[%{} = a] | _] = opts -> a 192 | end 193 | 194 | # Before 195 | with {:ok, result = %{}} <- foo, do: result 196 | # Styled 197 | with {:ok, %{} = result} <- foo, do: result 198 | 199 | # Before 200 | def foo(bar = %{baz: baz? = true}, opts = [[a = %{}] | _]), do: :ok 201 | # Styled 202 | def foo(%{baz: true = baz?} = bar, [[%{} = a] | _] = opts), do: :ok 203 | ``` 204 | 205 | ## Drops superfluous `= _` in pattern matching 206 | 207 | ```elixir 208 | # Before 209 | def foo(_ = bar), do: bar 210 | # Styled 211 | def foo(bar), do: bar 212 | 213 | # Before 214 | case foo do 215 | _ = bar -> :ok 216 | end 217 | # Styled 218 | case foo do 219 | bar -> :ok 220 | end 221 | ``` 222 | 223 | ## Shrink Function Definitions to One Line When Possible 224 | 225 | ```elixir 226 | # Before 227 | 228 | def save( 229 | # Socket comment 230 | %Socket{assigns: %{user: user, live_action: :new}} = initial_socket, 231 | # Params comment 232 | params 233 | ), 234 | do: :ok 235 | 236 | # Styled 237 | 238 | # Socket comment 239 | # Params comment 240 | def save(%Socket{assigns: %{user: user, live_action: :new}} = initial_socket, params), do: :ok 241 | ``` 242 | -------------------------------------------------------------------------------- /lib/alias_env.ex: -------------------------------------------------------------------------------- 1 | defmodule Styler.AliasEnv do 2 | @moduledoc """ 3 | A datastructure for maintaining something like compiler alias state when traversing AST. 4 | 5 | Not anywhere as correct as what the compiler gives us, but close enough for open source work. 6 | 7 | A alias env is a map from an alias's `as` to its resolution in a context. 8 | 9 | Given the ast for 10 | 11 | alias Foo.Bar 12 | 13 | we'd create the env: 14 | 15 | %{:Bar => [:Foo, :Bar]} 16 | """ 17 | def define(env \\ %{}, ast) 18 | 19 | def define(env, asts) when is_list(asts), do: Enum.reduce(asts, env, &define(&2, &1)) 20 | 21 | def define(env, {:alias, _, aliases}) do 22 | case aliases do 23 | [{:__aliases__, _, aliases}] -> define(env, aliases, List.last(aliases)) 24 | [{:__aliases__, _, aliases}, [{_as, {:__aliases__, _, [as]}}]] -> define(env, aliases, as) 25 | # `alias __MODULE__` or other oddities i'm not bothering to get right 26 | _ -> env 27 | end 28 | end 29 | 30 | defp define(env, modules, as), do: Map.put(env, as, do_expand(env, modules)) 31 | 32 | # no need to traverse ast if there are no aliases 33 | def expand(env, ast) when map_size(env) == 0, do: ast 34 | 35 | def expand(env, ast) do 36 | Macro.prewalk(ast, fn 37 | {:__aliases__, meta, modules} -> {:__aliases__, meta, do_expand(env, modules)} 38 | ast -> ast 39 | end) 40 | end 41 | 42 | # if the list of modules is itself already aliased, dealias it with the compound alias 43 | # given: 44 | # alias Foo.Bar 45 | # Bar.Baz.Bop.baz() 46 | # 47 | # lifting Bar.Baz.Bop should result in: 48 | # alias Foo.Bar 49 | # alias Foo.Bar.Baz.Bop 50 | # Bop.baz() 51 | defp do_expand(env, [first | rest] = modules) do 52 | if dealias = env[first], do: dealias ++ rest, else: modules 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/style.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Styler.Style do 12 | @moduledoc """ 13 | A Style takes AST and returns a transformed version of that AST. 14 | 15 | Because these transformations involve traversing trees (the "T" in "AST"), we wrap the AST in a structure 16 | called a Zipper to facilitate walking the trees. 17 | """ 18 | 19 | alias Styler.Zipper 20 | 21 | @type context :: %{ 22 | comments: [map()], 23 | file: :stdin | String.t() 24 | } 25 | 26 | @doc """ 27 | `run` will be used with `Zipper.traverse_while/3`, meaning it will be executed on every node of the AST. 28 | 29 | You can skip traversing parts of the tree by returning a Zipper that's further along in the traversal, for example 30 | by calling `Zipper.skip(zipper)` to skip an entire subtree you know is of no interest to your Style. 31 | """ 32 | @callback run(Zipper.t(), context()) :: {Zipper.command(), Zipper.t(), context()} 33 | 34 | @doc "Recursively sets `:line` meta to `line`. Deletes `:newlines` unless `delete_lines: false` is passed" 35 | def set_line(ast_node, line, opts \\ []) do 36 | set_line = fn _ -> line end 37 | 38 | if Keyword.get(opts, :delete_newlines, true) do 39 | update_all_meta(ast_node, &(&1 |> update_line(set_line) |> Keyword.delete(:newlines))) 40 | else 41 | update_all_meta(ast_node, &update_line(&1, set_line)) 42 | end 43 | end 44 | 45 | @doc "Recursively updates `:line` meta by adding `delta`" 46 | def shift_line(ast_node, delta) do 47 | shift_line = &(&1 + delta) 48 | update_all_meta(ast_node, &update_line(&1, shift_line)) 49 | end 50 | 51 | defp update_line(meta, fun) do 52 | Enum.map(meta, fn 53 | {:line, line} -> {:line, fun.(line)} 54 | {k, v} when is_list(v) -> {k, update_line(v, fun)} 55 | kv -> kv 56 | end) 57 | end 58 | 59 | @doc "Traverses an ast node, updating all nodes' meta with `meta_fun`" 60 | def update_all_meta(node, meta_fun), do: Macro.prewalk(node, &Macro.update_meta(&1, meta_fun)) 61 | 62 | @doc "prewalks ast and sets all meta to `nil`. useful for comparing AST without meta (line numbers, etc) interfering" 63 | def without_meta(ast), do: update_all_meta(ast, fn _ -> nil end) 64 | 65 | @doc """ 66 | Returns the current node (wrapped in a `__block__` if necessary) if it's a valid place to insert additional nodes 67 | """ 68 | @spec ensure_block_parent(Zipper.t()) :: {:ok, Zipper.t()} | :error 69 | def ensure_block_parent(zipper) do 70 | valid_block_location? = 71 | case Zipper.up(zipper) do 72 | {{:__block__, _, _}, _} -> true 73 | {{:->, _, _}, _} -> true 74 | {{_, _}, _} -> true 75 | nil -> true 76 | _ -> false 77 | end 78 | 79 | if valid_block_location? do 80 | {:ok, find_nearest_block(zipper)} 81 | else 82 | :error 83 | end 84 | end 85 | 86 | def do_block?([{{:__block__, _, [:do]}, _body} | _]), do: true 87 | def do_block?(_), do: false 88 | 89 | @doc """ 90 | Returns a zipper focused on the nearest node where additional nodes can be inserted (a "block"). 91 | 92 | The nearest node is either the current node, an ancestor, or one of those two but wrapped in a new `:__block__` node. 93 | """ 94 | @spec find_nearest_block(Zipper.t()) :: Zipper.t() 95 | def find_nearest_block(zipper) do 96 | case Zipper.up(zipper) do 97 | # parent is a block! 98 | {{:__block__, _, _}, _} -> zipper 99 | # when a statement is an only child, it doesn't get a block wrapper 100 | # only child of a right arrow 101 | {{:->, _, _}, _} -> wrap_in_block(zipper) 102 | # only child of a `do` block 103 | {{_, _}, _} -> wrap_in_block(zipper) 104 | # one line snippet 105 | nil -> wrap_in_block(zipper) 106 | # we're in a pipe, assignment, function call, etc. gotta keep going up looking for a block 107 | parent -> find_nearest_block(parent) 108 | end 109 | end 110 | 111 | # give it a block parent, then step back to the child - we can insert next to it now that it's in a block 112 | defp wrap_in_block(zipper) do 113 | zipper 114 | |> Zipper.update(fn {_, meta, _} = node -> {:__block__, Keyword.take(meta, [:line]), [node]} end) 115 | |> Zipper.down() 116 | end 117 | 118 | @doc """ 119 | Set the line of all comments with `line` in `range_start..range_end` to instead have line `range_start` 120 | """ 121 | def displace_comments(comments, range) do 122 | Enum.map(comments, fn comment -> 123 | if comment.line in range do 124 | %{comment | line: range.first} 125 | else 126 | comment 127 | end 128 | end) 129 | end 130 | 131 | @doc """ 132 | Change the `line` of all comments with `line` in `range` by adding `delta` to it. 133 | A positive delta will move the lines further down a file, while a negative delta will move them up. 134 | """ 135 | def shift_comments(comments, range, delta) do 136 | shift_comments(comments, [{range, delta}]) 137 | end 138 | 139 | @doc """ 140 | Perform a series of shifts in a single pass. 141 | 142 | When shifting comments from block A to block B, naively using two passes of `shift_comments/3` would result 143 | in all comments ending up in either region A or region B (because A would move to B, then all B back to A) 144 | This function exists to make sure that a comment is only moved once during the swap. 145 | """ 146 | def shift_comments(comments, shifts) do 147 | comments 148 | |> Enum.map(fn comment -> 149 | if delta = Enum.find_value(shifts, fn {range, delta} -> comment.line in range && delta end) do 150 | %{comment | line: max(comment.line + delta, 1)} 151 | else 152 | comment 153 | end 154 | end) 155 | |> Enum.sort_by(& &1.line) 156 | end 157 | 158 | @doc """ 159 | Takes a list of nodes and clumps them up, setting `end_of_expression: [newlines: x]` to 1 for all but the final node, 160 | which gets 2 instead, (hopefully!) creating an empty line before whatever follows. 161 | """ 162 | def reset_newlines([]), do: [] 163 | def reset_newlines(nodes), do: reset_newlines(nodes, []) 164 | 165 | def reset_newlines([node], acc), do: Enum.reverse([set_newlines(node, 2) | acc]) 166 | def reset_newlines([node | nodes], acc), do: reset_newlines(nodes, [set_newlines(node, 1) | acc]) 167 | 168 | defp set_newlines({directive, meta, children}, newline) do 169 | updated_meta = Keyword.update(meta, :end_of_expression, [newlines: newline], &Keyword.put(&1, :newlines, newline)) 170 | {directive, updated_meta, children} 171 | end 172 | 173 | def max_line([_ | _] = list), do: list |> List.last() |> max_line() 174 | 175 | def max_line(ast) do 176 | meta = meta(ast) 177 | 178 | cond do 179 | line = meta[:end_of_expression][:line] -> 180 | line 181 | 182 | line = meta[:closing][:line] -> 183 | line 184 | 185 | true -> 186 | {_, max_line} = 187 | Macro.prewalk(ast, 0, fn 188 | {_, meta, _} = ast, max -> {ast, max(meta[:line] || max, max)} 189 | ast, max -> {ast, max} 190 | end) 191 | 192 | max_line 193 | end 194 | end 195 | 196 | @doc "Sets the nodes' meta line and comments' line numbers to fit the ordering of the nodes list." 197 | # TODO this doesn't grab comments which are floating as their own paragrpah, unconnected to a node 198 | # they'll just be left floating where they were, then mangled with the re-ordered comments.. 199 | def order_line_meta_and_comments(nodes, comments, first_line) do 200 | {nodes, shifted_comments, comments, _line} = 201 | Enum.reduce(nodes, {[], [], comments, first_line}, fn node, {n_acc, c_acc, comments, move_to_line} -> 202 | meta = meta(node) 203 | line = meta[:line] 204 | last_line = max_line(node) 205 | {mine, comments} = comments_for_lines(comments, line, last_line) 206 | 207 | shift = move_to_line - (List.first(mine)[:line] || line) + 1 208 | shifted_node = shift_line(node, shift) 209 | shifted_comments = Enum.map(mine, &%{&1 | line: &1.line + shift}) 210 | 211 | move_to_line = last_line + shift + (meta[:end_of_expression][:newlines] || 0) 212 | 213 | {[shifted_node | n_acc], shifted_comments ++ c_acc, comments, move_to_line} 214 | end) 215 | 216 | {Enum.reverse(nodes), Enum.sort_by(comments ++ shifted_comments, & &1.line)} 217 | end 218 | 219 | # typical node 220 | def meta({_, meta, _}), do: meta 221 | # kwl tuple ala a: :b 222 | def meta({{_, meta, _}, _}), do: meta 223 | def meta(_), do: nil 224 | 225 | @doc """ 226 | Returns all comments "for" a node, including on the line before it. see `comments_for_lines` for more 227 | """ 228 | def comments_for_node({_, m, _} = node, comments), do: comments_for_lines(comments, m[:line], max_line(node)) 229 | 230 | @doc """ 231 | Gets all comments in range start_line..last_line, and any comments immediately before start_line.s 232 | 233 | 1. code 234 | 2. # a 235 | 3. # b 236 | 4. code # c 237 | 5. # d 238 | 6. code 239 | 7. # e 240 | 241 | here, comments_for_lines(comments, 4, 6) is "a", "b", "c", "d" 242 | """ 243 | def comments_for_lines(comments, start_line, last_line) do 244 | comments |> Enum.reverse() |> comments_for_lines(start_line, last_line, [], []) 245 | end 246 | 247 | defp comments_for_lines([%{line: line} = comment | rev_comments], start, last, match, acc) do 248 | cond do 249 | # after our block - no match 250 | line > last -> comments_for_lines(rev_comments, start, last, match, [comment | acc]) 251 | # after start, before last -- it's a match! 252 | line >= start -> comments_for_lines(rev_comments, start, last, [comment | match], acc) 253 | # this is a comment immediately before start, which means it's modifying this block... 254 | # we count that as a match, and look above it to see if it's a multiline comment 255 | line == start - 1 -> comments_for_lines(rev_comments, start - 1, last, [comment | match], acc) 256 | # comment before start - we've thus iterated through all comments which could be in our range 257 | true -> {match, Enum.reverse(rev_comments, [comment | acc])} 258 | end 259 | end 260 | 261 | defp comments_for_lines([], _, _, match, acc), do: {match, acc} 262 | end 263 | -------------------------------------------------------------------------------- /lib/style/comment_directives.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Styler.Style.CommentDirectives do 12 | @moduledoc """ 13 | Leave a comment for Styler asking it to maintain code in a certain way. 14 | 15 | `# styler:sort` maintains sorting of wordlists (by string comparison) and lists (string comparison of code representation) 16 | """ 17 | 18 | @behaviour Styler.Style 19 | 20 | alias Styler.Style 21 | alias Styler.Zipper 22 | 23 | def run(zipper, ctx) do 24 | {zipper, comments} = 25 | ctx.comments 26 | |> Enum.filter(&(&1.text == "# styler:sort")) 27 | |> Enum.map(& &1.line) 28 | |> Enum.reduce({zipper, ctx.comments}, fn line, {zipper, comments} -> 29 | found = 30 | Zipper.find(zipper, fn node -> 31 | node_line = Style.meta(node)[:line] || -1 32 | node_line >= line 33 | end) 34 | 35 | if found do 36 | {sorted, comments} = found |> Zipper.node() |> sort(comments) 37 | {Zipper.replace(found, sorted), comments} 38 | else 39 | {zipper, comments} 40 | end 41 | end) 42 | 43 | {:halt, zipper, %{ctx | comments: comments}} 44 | end 45 | 46 | # defstruct with a syntax-sugared keyword list hits here 47 | defp sort({parent, meta, [list]} = node, comments) when parent in ~w(defstruct __block__)a and is_list(list) do 48 | list = Enum.sort_by(list, &Macro.to_string/1) 49 | line = meta[:line] 50 | # no need to fix line numbers if it's a single line structure 51 | {list, comments} = 52 | if line == Style.max_line(node), 53 | do: {list, comments}, 54 | else: Style.order_line_meta_and_comments(list, comments, line) 55 | 56 | {{parent, meta, [list]}, comments} 57 | end 58 | 59 | # defstruct with a literal list 60 | defp sort({:defstruct, meta, [{:__block__, _, [_]} = list]}, comments) do 61 | {list, comments} = sort(list, comments) 62 | {{:defstruct, meta, [list]}, comments} 63 | end 64 | 65 | defp sort({:%{}, meta, list}, comments) when is_list(list) do 66 | {{:__block__, meta, [list]}, comments} = sort({:__block__, meta, [list]}, comments) 67 | {{:%{}, meta, list}, comments} 68 | end 69 | 70 | defp sort({:%, m, [struct, map]}, comments) do 71 | {map, comments} = sort(map, comments) 72 | {{:%, m, [struct, map]}, comments} 73 | end 74 | 75 | defp sort({:sigil_w, sm, [{:<<>>, bm, [string]}, modifiers]}, comments) do 76 | # ew. gotta be a better way. 77 | # this keeps indentation for the sigil via joiner, while prepend and append are the bookending whitespace 78 | {prepend, joiner, append} = 79 | case Regex.run(~r|^\s+|, string) do 80 | # oneliner like `~w|c a b|` 81 | nil -> {"", " ", ""} 82 | # multiline like 83 | # `"\n a\n list\n long\n of\n static\n values\n"` 84 | # ^^^^ `prepend` ^^^^ `joiner` ^^ `append` 85 | # note that joiner and prepend are the same in a multiline (unsure if this is always true) 86 | # @TODO: get all 3 in one pass of a regex. probably have to turn off greedy or something... 87 | [joiner] -> {joiner, joiner, ~r|\s+$| |> Regex.run(string) |> hd()} 88 | end 89 | 90 | string = string |> String.split() |> Enum.sort() |> Enum.join(joiner) 91 | {{:sigil_w, sm, [{:<<>>, bm, [prepend, string, append]}, modifiers]}, comments} 92 | end 93 | 94 | defp sort({:=, m, [lhs, rhs]}, comments) do 95 | {rhs, comments} = sort(rhs, comments) 96 | {{:=, m, [lhs, rhs]}, comments} 97 | end 98 | 99 | defp sort({:@, m, [{a, am, [assignment]}]}, comments) do 100 | {assignment, comments} = sort(assignment, comments) 101 | {{:@, m, [{a, am, [assignment]}]}, comments} 102 | end 103 | 104 | defp sort({key, value}, comments) do 105 | {value, comments} = sort(value, comments) 106 | {{key, value}, comments} 107 | end 108 | 109 | # sorts arbitrary ast nodes within a `do end` list 110 | defp sort({f, m, args} = node, comments) do 111 | if m[:do] && m[:end] && match?([{{:__block__, _, [:do]}, {:__block__, _, _}}], List.last(args)) do 112 | {[{{:__block__, m1, [:do]}, {:__block__, m2, nodes}}], args} = List.pop_at(args, -1) 113 | 114 | {nodes, comments} = 115 | nodes 116 | |> Enum.sort_by(&Macro.to_string/1) 117 | |> Style.order_line_meta_and_comments(comments, m[:line]) 118 | 119 | args = List.insert_at(args, -1, [{{:__block__, m1, [:do]}, {:__block__, m2, nodes}}]) 120 | 121 | {{f, m, args}, comments} 122 | else 123 | {node, comments} 124 | end 125 | end 126 | 127 | defp sort(x, comments), do: {x, comments} 128 | end 129 | -------------------------------------------------------------------------------- /lib/style/configs.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Styler.Style.Configs do 12 | @moduledoc """ 13 | Orders `Config.config/2,3` stanzas in configuration files. 14 | 15 | - ordering is done only within immediate-sibling config statements 16 | - assignments are moved above the configuration blocks 17 | - any non `config/2,3` or assignment (`=/2`) calls mark the end of a sorting block. 18 | this is support having conditional blocks (`if/case/cond`) and `import_config` stanzas between blocks 19 | 20 | ### Breakages 21 | 22 | If you configure the same values multiple times, Styler may swap their orders 23 | 24 | **Before** 25 | 26 | line 04: config :foo, bar: :zab 27 | line 40: config :foo, bar: :baz 28 | 29 | # Application.fetch_env!(:foo)[:bar] => :baz 30 | 31 | **After** 32 | 33 | line 04: config :foo, bar: :baz 34 | line 05: config :foo, bar: :zab 35 | 36 | # Application.fetch_env!(:foo)[:bar] => :zab 37 | 38 | **Fix** 39 | 40 | The reason Styler sorts configuration is to help you noticed these duplicated configuration stanzas. 41 | Delete the duplicative/erroneous stanza and life will be good. 42 | """ 43 | 44 | alias Styler.Style 45 | 46 | def run({{:import, _, [{:__aliases__, _, [:Config]}]}, _} = zipper, %{config?: true} = ctx) do 47 | {:skip, zipper, Map.put(ctx, :mix_config?, true)} 48 | end 49 | 50 | def run({{:config, cfm, [_, _ | _]} = config, zm}, %{mix_config?: true, comments: comments} = ctx) do 51 | {l, p, r} = zm 52 | # all of these list are reversed due to the reduce 53 | {configs, assignments, rest} = accumulate(r, [], []) 54 | # @TODO 55 | # okay so comments between nodes that we moved....... 56 | # lets just push them out of the way (???). so 57 | # 1. figure out first/last possible lines we're talking about here 58 | # 2. only pass comments in that range off 59 | # 3. split those comments into "moved, didn't move" 60 | # 4. for any "didn't move" comments... move them to the top? 61 | # 62 | # also, should i just do a scan of the configs ++ assignments, and see if any of them have lines out of order, 63 | # and decide from there whether or not i want to do set_lines 64 | 65 | configs = 66 | [config | configs] 67 | |> Enum.group_by(fn 68 | {:config, _, [{:__block__, _, [app]} | _]} -> app 69 | {:config, _, [arg | _]} -> Style.without_meta(arg) 70 | end) 71 | |> Enum.sort() 72 | |> Enum.flat_map(fn {_app, configs} -> 73 | configs 74 | |> Enum.sort_by(&Style.without_meta/1) 75 | |> Style.reset_newlines() 76 | end) 77 | 78 | nodes = 79 | assignments 80 | |> Enum.reverse() 81 | |> Style.reset_newlines() 82 | |> Enum.concat(configs) 83 | 84 | {nodes, comments} = 85 | if changed?(nodes) do 86 | # after running, this block should take up the same # of lines that it did before 87 | # the first node of `rest` is greater than the highest line in configs, assignments 88 | # config line is the first line to be used as part of this block 89 | {node_comments, _} = Style.comments_for_node(config, comments) 90 | first_line = min(List.first(node_comments)[:line] || cfm[:line], cfm[:line]) 91 | Style.order_line_meta_and_comments(nodes, comments, first_line) 92 | else 93 | {nodes, comments} 94 | end 95 | 96 | [config | left_siblings] = Enum.reverse(nodes, l) 97 | 98 | {:skip, {config, {left_siblings, p, rest}}, %{ctx | comments: comments}} 99 | end 100 | 101 | def run(zipper, %{config?: true} = ctx) do 102 | {:cont, zipper, ctx} 103 | end 104 | 105 | def run(zipper, %{file: file} = ctx) do 106 | if file =~ ~r|config/.*\.exs| or file =~ ~r|rel/overlays/.*\.exs| do 107 | # @TODO have this run forward to `import Config`, then run forward from there until we find `config` itself. no need for multi function head 108 | {:cont, zipper, Map.put(ctx, :config?, true)} 109 | else 110 | {:halt, zipper, ctx} 111 | end 112 | end 113 | 114 | defp changed?([{_, am, _}, {_, bm, _} = b | tail]) do 115 | if am[:line] > bm[:line], do: true, else: changed?([b | tail]) 116 | end 117 | 118 | defp changed?(_), do: false 119 | 120 | defp accumulate([{:config, _, [_, _ | _]} = c | siblings], cs, as), do: accumulate(siblings, [c | cs], as) 121 | defp accumulate([{:=, _, [_lhs, _rhs]} = a | siblings], cs, as), do: accumulate(siblings, cs, [a | as]) 122 | defp accumulate(rest, configs, assignments), do: {configs, assignments, rest} 123 | end 124 | -------------------------------------------------------------------------------- /lib/style/defs.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Styler.Style.Defs do 12 | @moduledoc """ 13 | Styles function heads so that they're as small as possible. 14 | 15 | The goal is that a function head fits on a single line. 16 | 17 | This isn't a Credo issue, and the formatter is fine with either approach. But Styler has opinions! 18 | 19 | Ex: 20 | 21 | This long declaration 22 | 23 | def foo(%{ 24 | bar: baz 25 | }) do 26 | ... 27 | end 28 | 29 | Becomes 30 | 31 | def foo(%{bar: baz}) do 32 | ... 33 | end 34 | """ 35 | 36 | @behaviour Styler.Style 37 | 38 | alias Styler.Style 39 | alias Styler.Zipper 40 | 41 | # Optimization / regression 42 | # it's non-trivial distinguishing `@def "foo"` from `def foo(...)` once you're deeper than the `@`, 43 | # so we're catching it here and skipping all module attribute nodes - shouldn't be defs inside them anyways 44 | def run({{:@, _, _}, _} = zipper, ctx), do: {:skip, zipper, ctx} 45 | 46 | # a def with no body like 47 | # 48 | # def example(foo, bar \\ nil) 49 | # 50 | def run({{def, meta, [head]}, _} = zipper, ctx) when def in [:def, :defp] do 51 | {_fn_name, head_meta, _children} = head 52 | first_line = meta[:line] 53 | last_line = head_meta[:closing][:line] 54 | 55 | # Already collapsed or it's a bodyless/paramless `def fun` 56 | if first_line == last_line || is_nil(last_line) do 57 | {:skip, zipper, ctx} 58 | else 59 | comments = Style.displace_comments(ctx.comments, first_line..last_line) 60 | node = {def, meta, [Style.set_line(head, first_line)]} 61 | {:skip, Zipper.replace(zipper, node), %{ctx | comments: comments}} 62 | end 63 | end 64 | 65 | def run({{def, def_meta, [head, [{{:__block__, dm, [:do]}, {_, bm, _}} | _] = body]}, _} = zipper, ctx) 66 | when def in [:def, :defp] do 67 | def_line = def_meta[:line] 68 | end_line = def_meta[:end][:line] || bm[:closing][:line] || dm[:line] 69 | 70 | cond do 71 | def_line == end_line -> 72 | {:skip, zipper, ctx} 73 | 74 | # def do end 75 | Keyword.has_key?(def_meta, :do) -> 76 | do_line = dm[:line] 77 | delta = def_line - do_line 78 | 79 | def_meta = 80 | def_meta 81 | |> put_in([:do, :line], def_line) 82 | |> update_in([:end, :line], &(&1 + delta)) 83 | 84 | head = Style.set_line(head, def_line) 85 | body = Style.shift_line(body, delta) 86 | node = {def, def_meta, [head, body]} 87 | 88 | comments = 89 | ctx.comments 90 | |> Style.displace_comments(def_line..do_line) 91 | |> Style.shift_comments(do_line..end_line, delta) 92 | 93 | {:skip, Zipper.replace(zipper, node), %{ctx | comments: comments}} 94 | 95 | # def , do: 96 | true -> 97 | node = Style.set_line({def, def_meta, [head, body]}, def_line) 98 | comments = Style.displace_comments(ctx.comments, def_line..end_line) 99 | {:skip, Zipper.replace(zipper, node), %{ctx | comments: comments}} 100 | end 101 | end 102 | 103 | def run(zipper, ctx), do: {:cont, zipper, ctx} 104 | end 105 | -------------------------------------------------------------------------------- /lib/style/deprecations.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Styler.Style.Deprecations do 12 | @moduledoc """ 13 | Transformations to soft or hard deprecations introduced on newer Elixir releases 14 | """ 15 | 16 | @behaviour Styler.Style 17 | 18 | def run({node, meta}, ctx), do: {:cont, {style(node), meta}, ctx} 19 | 20 | # Deprecated in 1.18 21 | # rewrite patterns of `first..last = ...` to `first..last//_ = ...` 22 | defp style({:=, m, [{:.., _, [_first, _last]} = range, rhs]}), do: {:=, m, [rewrite_range_match(range), rhs]} 23 | defp style({:->, m, [[{:.., _, [_first, _last]} = range], rhs]}), do: {:->, m, [[rewrite_range_match(range)], rhs]} 24 | defp style({:<-, m, [{:.., _, [_first, _last]} = range, rhs]}), do: {:<-, m, [rewrite_range_match(range), rhs]} 25 | 26 | defp style({def, dm, [{x, xm, params} | rest]}) when def in ~w(def defp)a and is_list(params), 27 | do: {def, dm, [{x, xm, Enum.map(params, &rewrite_range_match/1)} | rest]} 28 | 29 | # Deprecated in 1.18 30 | # List.zip => Enum.zip 31 | defp style({{:., dm_, [{:__aliases__, am, [:List]}, :zip]}, fm, arg}), 32 | do: {{:., dm_, [{:__aliases__, am, [:Enum]}, :zip]}, fm, arg} 33 | 34 | # Logger.warn => Logger.warning 35 | # Started to emit warning after Elixir 1.15.0 36 | defp style({{:., dm, [{:__aliases__, am, [:Logger]}, :warn]}, funm, args}), 37 | do: {{:., dm, [{:__aliases__, am, [:Logger]}, :warning]}, funm, args} 38 | 39 | # Path.safe_relative_to/2 => Path.safe_relative/2 40 | # TODO: Remove after Elixir v1.19 41 | defp style({{:., dm, [{_, _, [:Path]} = mod, :safe_relative_to]}, funm, args}), 42 | do: {{:., dm, [mod, :safe_relative]}, funm, args} 43 | 44 | # Pipe version for: 45 | # Path.safe_relative_to/2 => Path.safe_relative/2 46 | defp style({:|>, m, [lhs, {{:., dm, [{:__aliases__, am, [:Path]}, :safe_relative_to]}, funm, args}]}), 47 | do: {:|>, m, [lhs, {{:., dm, [{:__aliases__, am, [:Path]}, :safe_relative]}, funm, args}]} 48 | 49 | if Version.match?(System.version(), ">= 1.16.0-dev") do 50 | # File.stream!(file, options, line_or_bytes) => File.stream!(file, line_or_bytes, options) 51 | defp style({{:., _, [{_, _, [:File]}, :stream!]} = f, fm, [path, {:__block__, _, [modes]} = opts, lob]}) 52 | when is_list(modes), 53 | do: {f, fm, [path, lob, opts]} 54 | 55 | # Pipe version for File.stream! 56 | defp style({:|>, m, [lhs, {{_, _, [{_, _, [:File]}, :stream!]} = f, fm, [{:__block__, _, [modes]} = opts, lob]}]}) 57 | when is_list(modes), 58 | do: {:|>, m, [lhs, {f, fm, [lob, opts]}]} 59 | end 60 | 61 | if Version.match?(System.version(), ">= 1.17.0-dev") do 62 | for {erl, ex} <- [hours: :hour, minutes: :minute, seconds: :second] do 63 | defp style({{:., _, [{:__block__, _, [:timer]}, unquote(erl)]}, fm, [x]}), 64 | do: {:to_timeout, fm, [[{{:__block__, [format: :keyword, line: fm[:line]], [unquote(ex)]}, x}]]} 65 | end 66 | end 67 | 68 | # Struct update syntax is deprecated in 1.19 69 | # `%Foo{x | y} => %{x | y}` 70 | defp style({:%, _, [_struct, {:%{}, _, [{:|, _, _}]} = update]}), do: update 71 | 72 | # For ranges where `start > stop`, you need to explicitly include the step 73 | # Enum.slice(enumerable, 1..-2) => Enum.slice(enumerable, 1..-2//1) 74 | # String.slice("elixir", 2..-1) => String.slice("elixir", 2..-1//1) 75 | defp style({{:., _, [{_, _, [module]}, :slice]} = f, funm, [enumerable, {:.., _, [_, _]} = range]}) 76 | when module in [:Enum, :String], 77 | do: {f, funm, [enumerable, add_step_to_decreasing_range(range)]} 78 | 79 | # Pipe version for {Enum,String}.slice 80 | defp style({:|>, m, [lhs, {{:., _, [{_, _, [mod]}, :slice]} = f, funm, [{:.., _, [_, _]} = range]}]}) 81 | when mod in [:Enum, :String], 82 | do: {:|>, m, [lhs, {f, funm, [add_step_to_decreasing_range(range)]}]} 83 | 84 | # ~R is deprecated in favor of ~r 85 | defp style({:sigil_R, m, args}), do: {:sigil_r, m, args} 86 | 87 | # For a decreasing range, we must use Date.range/3 instead of Date.range/2 88 | defp style({{:., _, [{:__aliases__, _, [:Date]}, :range]} = funm, dm, [first, last]} = block) do 89 | if add_step_to_date_range?(first, last), 90 | do: {funm, dm, [first, last, -1]}, 91 | else: block 92 | end 93 | 94 | # Pipe version for Date.range/2 95 | defp style({:|>, pm, [first, {{:., _, [{:__aliases__, _, [:Date]}, :range]} = funm, dm, [last]}]} = pipe) do 96 | if add_step_to_date_range?(first, last), 97 | do: {:|>, pm, [first, {funm, dm, [last, -1]}]}, 98 | else: pipe 99 | end 100 | 101 | # use :eof instead of :all in IO.read/2 and IO.binread/2 102 | defp style({{:., _, [{:__aliases__, _, [:IO]}, fun]} = fm, dm, [{:__block__, am, [:all]}]}) 103 | when fun in [:read, :binread], 104 | do: {fm, dm, [{:__block__, am, [:eof]}]} 105 | 106 | defp style({{:., _, [{:__aliases__, _, [:IO]}, fun]} = fm, dm, [device, {:__block__, am, [:all]}]}) 107 | when fun in [:read, :binread], 108 | do: {fm, dm, [device, {:__block__, am, [:eof]}]} 109 | 110 | defp style(node), do: node 111 | 112 | defp rewrite_range_match({:.., dm, [first, {_, m, _} = last]}), do: {:..//, dm, [first, last, {:_, m, nil}]} 113 | defp rewrite_range_match(x), do: x 114 | 115 | defp add_step_to_date_range?(first, last) do 116 | with {:ok, f} <- extract_date_value(first), 117 | {:ok, l} <- extract_date_value(last), 118 | # for ex1.14 compat, use compare instead of after? 119 | :gt <- Date.compare(f, l) do 120 | true 121 | else 122 | _ -> false 123 | end 124 | end 125 | 126 | defp add_step_to_decreasing_range({:.., rm, [first, {_, lm, _} = last]} = range) do 127 | with {:ok, start} <- extract_value_from_range(first), 128 | {:ok, stop} <- extract_value_from_range(last), 129 | true <- start > stop do 130 | step = {:__block__, [token: "1", line: lm[:line]], [1]} 131 | {:..//, rm, [first, last, step]} 132 | else 133 | _ -> range 134 | end 135 | end 136 | 137 | # Extracts the positive or negative integer from the given range block 138 | defp extract_value_from_range({:__block__, _, [value]}) when is_integer(value), do: {:ok, value} 139 | defp extract_value_from_range({:-, _, [{:__block__, _, [value]}]}) when is_integer(value), do: {:ok, -value} 140 | defp extract_value_from_range(_), do: :non_int 141 | 142 | defp extract_date_value({:sigil_D, _, [{:<<>>, _, [date]}, []]}), do: Date.from_iso8601(date) 143 | defp extract_date_value(_), do: :unknown 144 | end 145 | -------------------------------------------------------------------------------- /lib/style/single_node.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Styler.Style.SingleNode do 12 | @moduledoc """ 13 | Simple 1-1 rewrites all crammed into one module to make for more efficient traversals 14 | 15 | Credo Rules addressed: 16 | 17 | * Credo.Check.Consistency.ParameterPatternMatching 18 | * Credo.Check.Readability.LargeNumbers 19 | * Credo.Check.Readability.ParenthesesOnZeroArityDefs 20 | * Credo.Check.Readability.PreferImplicitTry 21 | * Credo.Check.Readability.StringSigils 22 | * Credo.Check.Readability.WithSingleClause 23 | * Credo.Check.Refactor.CaseTrivialMatches 24 | * Credo.Check.Refactor.CondStatements 25 | * Credo.Check.Refactor.RedundantWithClauseResult 26 | * Credo.Check.Refactor.WithClauses 27 | """ 28 | 29 | @behaviour Styler.Style 30 | 31 | @closing_delimiters [~s|"|, ")", "}", "|", "]", "'", ">", "/"] 32 | 33 | # `|> Timex.now()` => `|> Timex.now()` 34 | # skip over pipes into `Timex.now/1` so that we don't accidentally rewrite it as DateTime.utc_now/1 35 | def run({{:|>, _, [_, {{:., _, [{:__aliases__, _, [:Timex]}, :now]}, _, []}]}, _} = zipper, ctx), 36 | do: {:skip, zipper, ctx} 37 | 38 | def run({node, meta}, ctx), do: {:cont, {style(node), meta}, ctx} 39 | 40 | # rewrite double-quote strings with >= 4 escaped double-quotes as sigils 41 | defp style({:__block__, [{:delimiter, ~s|"|} | meta], [string]} = node) when is_binary(string) do 42 | # running a regex against every double-quote delimited string literal in a codebase doesn't have too much impact 43 | # on adobe's internal codebase, but perhaps other codebases have way more literals where this'd have an impact? 44 | if string =~ ~r/".*".*".*"/ do 45 | # choose whichever delimiter would require the least # of escapes, 46 | # ties being broken by our stylish ordering of delimiters (reflected in the 1-8 values) 47 | {closer, _} = 48 | string 49 | |> String.codepoints() 50 | |> Stream.filter(&(&1 in @closing_delimiters)) 51 | |> Stream.concat(@closing_delimiters) 52 | |> Enum.frequencies() 53 | |> Enum.min_by(fn 54 | {~s|"|, count} -> {count, 1} 55 | {")", count} -> {count, 2} 56 | {"}", count} -> {count, 3} 57 | {"|", count} -> {count, 4} 58 | {"]", count} -> {count, 5} 59 | {"'", count} -> {count, 6} 60 | {">", count} -> {count, 7} 61 | {"/", count} -> {count, 8} 62 | end) 63 | 64 | delimiter = 65 | case closer do 66 | ")" -> "(" 67 | "}" -> "{" 68 | "]" -> "[" 69 | ">" -> "<" 70 | closer -> closer 71 | end 72 | 73 | {:sigil_s, [{:delimiter, delimiter} | meta], [{:<<>>, [line: meta[:line]], [string]}, []]} 74 | else 75 | node 76 | end 77 | end 78 | 79 | # Add / Correct `_` location in large numbers. Formatter handles large number (>5 digits) rewrites, 80 | # but doesn't rewrite typos like `100_000_0`, so it's worthwhile to have Styler do this 81 | # 82 | # `?-` isn't part of the number node - it's its parent - so all numbers are positive at this point 83 | defp style({:__block__, meta, [number]}) when is_number(number) and number >= 10_000 do 84 | # Checking here rather than in the anonymous function due to compiler bug https://github.com/elixir-lang/elixir/issues/10485 85 | integer? = is_integer(number) 86 | 87 | meta = 88 | Keyword.update!(meta, :token, fn 89 | "0x" <> _ = token -> 90 | token 91 | 92 | "0b" <> _ = token -> 93 | token 94 | 95 | "0o" <> _ = token -> 96 | token 97 | 98 | token when integer? -> 99 | delimit(token) 100 | 101 | # is float 102 | token -> 103 | [int_token, decimals] = String.split(token, ".") 104 | "#{delimit(int_token)}.#{decimals}" 105 | end) 106 | 107 | {:__block__, meta, [number]} 108 | end 109 | 110 | ## INEFFICIENT FUNCTION REWRITES 111 | # Keep in mind when rewriting a `/n::pos_integer` arity function here that it should also be added 112 | # to the pipes rewriting rules, where it will appear as `/n-1` 113 | 114 | # Enum.into(enum, empty_map[, ...]) => Map.new(enum[, ...]) 115 | defp style({{:., _, [{:__aliases__, _, [:Enum]}, :into]} = into, m, [enum, collectable | rest]} = node) do 116 | if replacement = replace_into(into, collectable, rest), do: {replacement, m, [enum | rest]}, else: node 117 | end 118 | 119 | # lhs |> Enum.into(%{}, ...) => lhs |> Map.new(...) 120 | defp style({:|>, meta, [lhs, {{:., _, [{_, _, [:Enum]}, :into]} = into, m, [collectable | rest]}]} = node) do 121 | if replacement = replace_into(into, collectable, rest), do: {:|>, meta, [lhs, {replacement, m, rest}]}, else: node 122 | end 123 | 124 | for m <- [:Map, :Keyword] do 125 | # lhs |> Map.merge(%{key: value}) => lhs |> Map.put(key, value) 126 | defp style({:|>, pm, [lhs, {{:., dm, [{_, _, [unquote(m)]} = module, :merge]}, m, [{:%{}, _, [{key, value}]}]}]}), 127 | do: {:|>, pm, [lhs, {{:., dm, [module, :put]}, m, [key, value]}]} 128 | 129 | # lhs |> Map.merge(key: value) => lhs |> Map.put(:key, value) 130 | defp style({:|>, pm, [lhs, {{:., dm, [{_, _, [unquote(m)]} = module, :merge]}, m, [[{key, value}]]}]}), 131 | do: {:|>, pm, [lhs, {{:., dm, [module, :put]}, m, [key, value]}]} 132 | 133 | # Map.merge(foo, %{one_key: :bar}) => Map.put(foo, :one_key, :bar) 134 | defp style({{:., dm, [{_, _, [unquote(m)]} = module, :merge]}, m, [lhs, {:%{}, _, [{key, value}]}]}), 135 | do: {{:., dm, [module, :put]}, m, [lhs, key, value]} 136 | 137 | # Map.merge(foo, one_key: :bar) => Map.put(foo, :one_key, :bar) 138 | defp style({{:., dm, [{_, _, [unquote(m)]} = module, :merge]}, m, [lhs, [{key, value}]]}), 139 | do: {{:., dm, [module, :put]}, m, [lhs, key, value]} 140 | 141 | # (lhs |>) Map.drop([key]) => Map.delete(key) 142 | defp style({{:., dm, [{_, _, [unquote(m)]} = module, :drop]}, m, [{:__block__, _, [[{op, _, _} = key]]}]}) 143 | when op != :|, 144 | do: {{:., dm, [module, :delete]}, m, [key]} 145 | 146 | # Map.drop(foo, [one_key]) => Map.delete(foo, one_key) 147 | defp style({{:., dm, [{_, _, [unquote(m)]} = module, :drop]}, m, [lhs, {:__block__, _, [[{op, _, _} = key]]}]}) 148 | when op != :|, 149 | do: {{:., dm, [module, :delete]}, m, [lhs, key]} 150 | end 151 | 152 | # Timex.now() => DateTime.utc_now() 153 | defp style({{:., dm, [{:__aliases__, am, [:Timex]}, :now]}, funm, []}), 154 | do: {{:., dm, [{:__aliases__, am, [:DateTime]}, :utc_now]}, funm, []} 155 | 156 | # {DateTime,NaiveDateTime,Time,Date}.compare(a, b) == :lt => {DateTime,NaiveDateTime,Time,Date}.before?(a, b) 157 | # {DateTime,NaiveDateTime,Time,Date}.compare(a, b) == :gt => {DateTime,NaiveDateTime,Time,Date}.after?(a, b) 158 | defp style({:==, _, [{{:., dm, [{:__aliases__, am, [mod]}, :compare]}, funm, args}, {:__block__, _, [result]}]}) 159 | when mod in ~w[DateTime NaiveDateTime Time Date]a and result in [:lt, :gt] do 160 | fun = if result == :lt, do: :before?, else: :after? 161 | {{:., dm, [{:__aliases__, am, [mod]}, fun]}, funm, args} 162 | end 163 | 164 | # Remove parens from 0 arity funs (Credo.Check.Readability.ParenthesesOnZeroArityDefs) 165 | defp style({def, dm, [{fun, funm, []} | rest]}) when def in ~w(def defp)a and is_atom(fun), 166 | do: style({def, dm, [{fun, Keyword.delete(funm, :closing), nil} | rest]}) 167 | 168 | # `Credo.Check.Readability.PreferImplicitTry` 169 | defp style({def, dm, [head, [{_, {:try, _, [try_children]}}]]}) when def in ~w(def defp)a, 170 | do: style({def, dm, [head, try_children]}) 171 | 172 | defp style({def, dm, [{fun, funm, params} | rest]}) when def in ~w(def defp)a, 173 | do: {def, dm, [{fun, funm, put_matches_on_right(params)} | rest]} 174 | 175 | # `Enum.reverse(foo) ++ bar` => `Enum.reverse(foo, bar)` 176 | defp style({:++, _, [{{:., _, [{_, _, [:Enum]}, :reverse]} = reverse, r_meta, [lhs]}, rhs]}), 177 | do: {reverse, r_meta, [lhs, rhs]} 178 | 179 | # ARROW REWRITES 180 | # `with`, `for` left arrow - if only we could write something this trivial for `->`! 181 | defp style({:<-, cm, [lhs, rhs]}), do: {:<-, cm, [put_matches_on_right(lhs), rhs]} 182 | # there's complexity to `:->` due to `cond` also utilizing the symbol but with different semantics. 183 | # thus, we have to have a clause for each place that `:->` can show up 184 | # `with` elses 185 | defp style({{:__block__, _, [:else]} = else_, arrows}), do: {else_, rewrite_arrows(arrows)} 186 | defp style({:case, cm, [head, [{do_, arrows}]]}), do: {:case, cm, [head, [{do_, rewrite_arrows(arrows)}]]} 187 | defp style({:fn, m, arrows}), do: {:fn, m, rewrite_arrows(arrows)} 188 | 189 | defp style({:to_timeout, meta, [[{{:__block__, um, [unit]}, {:*, _, [left, right]}}]]} = node) 190 | when unit in ~w(day hour minute second millisecond)a do 191 | [l, r] = 192 | Enum.map([left, right], fn 193 | {_, _, [x]} -> x 194 | _ -> nil 195 | end) 196 | 197 | {step, next_unit} = 198 | case unit do 199 | :day -> {7, :week} 200 | :hour -> {24, :day} 201 | :minute -> {60, :hour} 202 | :second -> {60, :minute} 203 | :millisecond -> {1000, :second} 204 | end 205 | 206 | if step in [l, r] do 207 | n = if l == step, do: right, else: left 208 | style({:to_timeout, meta, [[{{:__block__, um, [next_unit]}, n}]]}) 209 | else 210 | node 211 | end 212 | end 213 | 214 | defp style({:to_timeout, meta, [[{{:__block__, um, [unit]}, {:__block__, tm, [n]}}]]} = node) do 215 | step_up = 216 | case {unit, n} do 217 | {:day, 7} -> :week 218 | {:hour, 24} -> :day 219 | {:minute, 60} -> :hour 220 | {:second, 60} -> :minute 221 | {:millisecond, 1000} -> :second 222 | _ -> nil 223 | end 224 | 225 | if step_up do 226 | {:to_timeout, meta, [[{{:__block__, um, [step_up]}, {:__block__, [token: "1", line: tm[:line]], [1]}}]]} 227 | else 228 | node 229 | end 230 | end 231 | 232 | defp style(node), do: node 233 | 234 | defp replace_into({:., dm, [{_, am, _} = enum, _]}, collectable, rest) do 235 | case collectable do 236 | {{:., _, [{_, _, [mod]}, :new]}, _, []} when mod in ~w(Map Keyword MapSet)a -> 237 | {:., dm, [{:__aliases__, am, [mod]}, :new]} 238 | 239 | {:%{}, _, []} -> 240 | {:., dm, [{:__aliases__, am, [:Map]}, :new]} 241 | 242 | {:__block__, _, [[]]} -> 243 | if Enum.empty?(rest), do: {:., dm, [enum, :to_list]}, else: {:., dm, [enum, :map]} 244 | 245 | _ -> 246 | nil 247 | end 248 | end 249 | 250 | defp rewrite_arrows(arrows) when is_list(arrows), 251 | do: Enum.map(arrows, fn {:->, m, [lhs, rhs]} -> {:->, m, [put_matches_on_right(lhs), rhs]} end) 252 | 253 | defp rewrite_arrows(macros_or_something_crazy_oh_no_abooort), do: macros_or_something_crazy_oh_no_abooort 254 | 255 | defp put_matches_on_right(ast) do 256 | Macro.prewalk(ast, fn 257 | # `_ = var ->` => `var ->` 258 | {:=, _, [{:_, _, nil}, var]} -> var 259 | # `var = _ ->` => `var ->` 260 | {:=, _, [var, {:_, _, nil}]} -> var 261 | # `var = *match*` -> `*match -> var` 262 | {:=, m, [{_, _, nil} = var, match]} -> {:=, m, [match, var]} 263 | node -> node 264 | end) 265 | end 266 | 267 | defp delimit(token), do: token |> String.to_charlist() |> remove_underscores([]) |> add_underscores([]) 268 | 269 | defp remove_underscores([?_ | rest], acc), do: remove_underscores(rest, acc) 270 | defp remove_underscores([digit | rest], acc), do: remove_underscores(rest, [digit | acc]) 271 | defp remove_underscores([], reversed_list), do: reversed_list 272 | 273 | defp add_underscores([a, b, c, d | rest], acc), do: add_underscores([d | rest], [?_, c, b, a | acc]) 274 | defp add_underscores(reversed_list, acc), do: reversed_list |> Enum.reverse(acc) |> to_string() 275 | end 276 | -------------------------------------------------------------------------------- /lib/style_error.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Styler.StyleError do 12 | @moduledoc """ 13 | Wraps errors raised by Styles during tree traversal. 14 | """ 15 | defexception [:exception, :style, :file] 16 | 17 | def message(%{exception: exception, style: style, file: file}) do 18 | file = file && if file == :std, do: "stdin", else: Path.relative_to_cwd(file) 19 | style = style |> Module.split() |> List.last() 20 | 21 | """ 22 | Error running style #{style} on #{file} 23 | Please consider opening an issue at: #{IO.ANSI.light_green()}https://github.com/adobe/elixir-styler/issues/new#{IO.ANSI.reset()} 24 | #{IO.ANSI.default_color()}#{Exception.format(:error, exception)} 25 | """ 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/styler.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Styler do 12 | @moduledoc """ 13 | Styler is a formatter plugin with stronger opinions on code organization, multi-line defs and other code-style matters. 14 | """ 15 | @behaviour Mix.Tasks.Format 16 | 17 | alias Mix.Tasks.Format 18 | alias Styler.StyleError 19 | alias Styler.Zipper 20 | 21 | @styles [ 22 | Styler.Style.ModuleDirectives, 23 | Styler.Style.Pipes, 24 | Styler.Style.Deprecations, 25 | Styler.Style.SingleNode, 26 | Styler.Style.Defs, 27 | Styler.Style.Blocks, 28 | Styler.Style.Configs, 29 | Styler.Style.CommentDirectives 30 | ] 31 | 32 | @doc false 33 | def style({ast, comments}, file, opts) do 34 | on_error = opts[:on_error] || :log 35 | Styler.Config.set(opts) 36 | zipper = Zipper.zip(ast) 37 | 38 | {{ast, _}, comments} = 39 | Enum.reduce(@styles, {zipper, comments}, fn style, {zipper, comments} -> 40 | context = %{comments: comments, file: file} 41 | 42 | try do 43 | {zipper, %{comments: comments}} = Zipper.traverse_while(zipper, context, &style.run/2) 44 | {zipper, comments} 45 | rescue 46 | exception -> 47 | exception = StyleError.exception(exception: exception, style: style, file: file) 48 | 49 | if on_error == :log do 50 | error = Exception.format(:error, exception, __STACKTRACE__) 51 | Mix.shell().error("#{error}\n#{IO.ANSI.reset()}Skipping style and continuing on") 52 | {zipper, context} 53 | else 54 | reraise exception, __STACKTRACE__ 55 | end 56 | end 57 | end) 58 | 59 | {ast, comments} 60 | end 61 | 62 | @impl Format 63 | def features(_opts), do: [sigils: [], extensions: [".ex", ".exs"]] 64 | 65 | @impl Format 66 | def format(input, formatter_opts \\ []) do 67 | file = formatter_opts[:file] 68 | styler_opts = formatter_opts[:styler] || [] 69 | 70 | {ast, comments} = 71 | input 72 | |> string_to_ast(to_string(file)) 73 | |> style(file, styler_opts) 74 | 75 | ast_to_string(ast, comments, formatter_opts) 76 | end 77 | 78 | @doc false 79 | # Wrap `Code.string_to_quoted_with_comments` with our desired options 80 | def string_to_ast(code, file \\ "nofile") when is_binary(code) do 81 | Code.string_to_quoted_with_comments!(code, 82 | literal_encoder: &__MODULE__.literal_encoder/2, 83 | token_metadata: true, 84 | unescape: false, 85 | file: file 86 | ) 87 | end 88 | 89 | @doc false 90 | def literal_encoder(literal, meta), do: {:ok, {:__block__, meta, [literal]}} 91 | 92 | @doc "Turns an ast and comments back into code, formatting it along the way." 93 | def ast_to_string(ast, comments \\ [], formatter_opts \\ []) do 94 | opts = [{:comments, comments}, {:escape, false} | formatter_opts] 95 | {line_length, opts} = Keyword.pop(opts, :line_length, 122) 96 | 97 | formatted = 98 | ast 99 | |> Code.quoted_to_algebra(opts) 100 | |> Inspect.Algebra.format(line_length) 101 | 102 | IO.iodata_to_binary([formatted, ?\n]) 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/styler/config.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Styler.Config do 12 | @moduledoc false 13 | @key __MODULE__ 14 | 15 | @stdlib MapSet.new(~w( 16 | Access Agent Application Atom Base Behaviour Bitwise Code Date DateTime Dict Ecto Enum Exception 17 | File Float GenEvent GenServer HashDict HashSet Integer IO Kernel Keyword List 18 | Macro Map MapSet Module NaiveDateTime Node Oban OptionParser Path Port Process Protocol 19 | Range Record Regex Registry Set Stream String StringIO Supervisor System Task Time Tuple URI Version 20 | )a) 21 | 22 | def set(config) do 23 | :persistent_term.get(@key) 24 | :ok 25 | rescue 26 | ArgumentError -> set!(config) 27 | end 28 | 29 | def set!(config) do 30 | excludes = 31 | config[:alias_lifting_exclude] 32 | |> List.wrap() 33 | |> MapSet.new(fn 34 | atom when is_atom(atom) -> 35 | case to_string(atom) do 36 | "Elixir." <> rest -> String.to_atom(rest) 37 | _ -> atom 38 | end 39 | 40 | other -> 41 | raise "Expected an atom for `alias_lifting_exclude`, got: #{inspect(other)}" 42 | end) 43 | |> MapSet.union(@stdlib) 44 | 45 | :persistent_term.put(@key, %{ 46 | lifting_excludes: excludes 47 | }) 48 | end 49 | 50 | def get(key) do 51 | @key 52 | |> :persistent_term.get() 53 | |> Map.fetch!(key) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Styler.MixProject do 12 | use Mix.Project 13 | 14 | # Don't forget to bump the README when doing non-patch version changes 15 | @version "1.4.2" 16 | @url "https://github.com/adobe/elixir-styler" 17 | 18 | def project do 19 | [ 20 | app: :styler, 21 | version: @version, 22 | elixir: "~> 1.15", 23 | start_permanent: Mix.env() == :prod, 24 | elixirc_paths: elixirc_paths(Mix.env()), 25 | deps: deps(), 26 | 27 | ## Hex 28 | package: package(), 29 | description: "A code-style enforcer that will just FIFY instead of complaining", 30 | 31 | # Docs 32 | name: "Styler", 33 | docs: docs() 34 | ] 35 | end 36 | 37 | defp elixirc_paths(:test), do: ["lib", "test/support"] 38 | defp elixirc_paths(_), do: ["lib"] 39 | 40 | def application, do: [extra_applications: [:logger]] 41 | 42 | defp deps do 43 | [ 44 | {:ex_doc, "~> 0.31", runtime: false, only: :dev} 45 | ] 46 | end 47 | 48 | defp package do 49 | [ 50 | maintainers: ["Matt Enlow", "Greg Mefford"], 51 | licenses: ["Apache-2.0"], 52 | links: %{"GitHub" => @url} 53 | ] 54 | end 55 | 56 | defp docs do 57 | [ 58 | main: "readme", 59 | source_ref: "v#{@version}", 60 | source_url: @url, 61 | groups_for_extras: [ 62 | Rewrites: ~r/docs/ 63 | ], 64 | extra_section: "Docs", 65 | extras: [ 66 | "CHANGELOG.md": [title: "Changelog"], 67 | "docs/styles.md": [title: "Basic Styles"], 68 | "docs/deprecations.md": [title: "Deprecated Elixirisms"], 69 | "docs/pipes.md": [title: "Pipe Chains"], 70 | "docs/control_flow_macros.md": [title: "Control Flow Macros (if, case, ...)"], 71 | "docs/mix_configs.md": [title: "Mix Configs (config/*.exs)"], 72 | "docs/module_directives.md": [title: "Module Directives (use, alias, ...)"], 73 | "docs/comment_directives.md": [title: "Comment Directives (# styler:sort)"], 74 | "docs/credo.md": [title: "Styler & Credo"], 75 | "README.md": [title: "Styler"] 76 | ] 77 | ] 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, 3 | "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"}, 4 | "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"}, 5 | "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"}, 6 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.3", "d684f4bac8690e70b06eb52dad65d26de2eefa44cd19d64a8095e1417df7c8fd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "b78dc853d2e670ff6390b605d807263bf606da3c82be37f9d7f68635bd886fc9"}, 7 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 8 | } 9 | -------------------------------------------------------------------------------- /test/config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Styler.ConfigTest do 2 | use ExUnit.Case, async: false 3 | 4 | import Styler.Config 5 | 6 | test "no config is good times" do 7 | assert :ok = set!([]) 8 | end 9 | 10 | describe "alias_lifting_exclude" do 11 | test "takes singletons atom" do 12 | set!(alias_lifting_exclude: Foo) 13 | assert %MapSet{} = excludes = get(:lifting_excludes) 14 | assert :Foo in excludes 15 | refute Foo in excludes 16 | 17 | set!(alias_lifting_exclude: :Foo) 18 | assert %MapSet{} = excludes = get(:lifting_excludes) 19 | assert :Foo in excludes 20 | end 21 | 22 | test "list of atoms" do 23 | set!(alias_lifting_exclude: [Foo, :Bar]) 24 | assert %MapSet{} = excludes = get(:lifting_excludes) 25 | assert :Foo in excludes 26 | refute Foo in excludes 27 | assert :Bar in excludes 28 | end 29 | 30 | test "raises on non-atom inputs" do 31 | assert_raise RuntimeError, ~r"Expected an atom", fn -> 32 | set!(alias_lifting_exclude: ["Bar"]) 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/style/comment_directives_test.exs: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Styler.Style.CommentDirectivesTest do 12 | @moduledoc false 13 | use Styler.StyleCase, async: true 14 | 15 | describe "sort" do 16 | test "we dont just sort by accident" do 17 | assert_style "[:c, :b, :a]" 18 | end 19 | 20 | test "sorts lists of atoms" do 21 | assert_style( 22 | """ 23 | # styler:sort 24 | [ 25 | :c, 26 | :b, 27 | :c, 28 | :a 29 | ] 30 | """, 31 | """ 32 | # styler:sort 33 | [ 34 | :a, 35 | :b, 36 | :c, 37 | :c 38 | ] 39 | """ 40 | ) 41 | end 42 | 43 | test "sort keywordy things" do 44 | assert_style( 45 | """ 46 | # styler:sort 47 | [ 48 | c: 2, 49 | b: 3, 50 | a: 4, 51 | d: 1 52 | ] 53 | """, 54 | """ 55 | # styler:sort 56 | [ 57 | a: 4, 58 | b: 3, 59 | c: 2, 60 | d: 1 61 | ] 62 | """ 63 | ) 64 | 65 | assert_style( 66 | """ 67 | # styler:sort 68 | %{ 69 | c: 2, 70 | b: 3, 71 | a: 4, 72 | d: 1 73 | } 74 | """, 75 | """ 76 | # styler:sort 77 | %{ 78 | a: 4, 79 | b: 3, 80 | c: 2, 81 | d: 1 82 | } 83 | """ 84 | ) 85 | 86 | assert_style( 87 | """ 88 | # styler:sort 89 | %Struct{ 90 | c: 2, 91 | b: 3, 92 | a: 4, 93 | d: 1 94 | } 95 | """, 96 | """ 97 | # styler:sort 98 | %Struct{ 99 | a: 4, 100 | b: 3, 101 | c: 2, 102 | d: 1 103 | } 104 | """ 105 | ) 106 | 107 | assert_style( 108 | """ 109 | # styler:sort 110 | defstruct c: 2, b: 3, a: 4, d: 1 111 | """, 112 | """ 113 | # styler:sort 114 | defstruct a: 4, b: 3, c: 2, d: 1 115 | """ 116 | ) 117 | 118 | assert_style( 119 | """ 120 | # styler:sort 121 | defstruct [ 122 | :repo, 123 | :query, 124 | :order, 125 | :chunk_size, 126 | :timeout, 127 | :cursor 128 | ] 129 | """, 130 | """ 131 | # styler:sort 132 | defstruct [ 133 | :chunk_size, 134 | :cursor, 135 | :order, 136 | :query, 137 | :repo, 138 | :timeout 139 | ] 140 | """ 141 | ) 142 | end 143 | 144 | test "inside keywords" do 145 | assert_style( 146 | """ 147 | %{ 148 | key: 149 | # styler:sort 150 | [ 151 | 3, 152 | 2, 153 | 1 154 | ] 155 | } 156 | """, 157 | """ 158 | %{ 159 | # styler:sort 160 | key: [ 161 | 1, 162 | 2, 163 | 3 164 | ] 165 | } 166 | """ 167 | ) 168 | 169 | assert_style( 170 | """ 171 | %{ 172 | # styler:sort 173 | key: [ 174 | 3, 175 | 2, 176 | 1 177 | ] 178 | } 179 | """, 180 | """ 181 | %{ 182 | # styler:sort 183 | key: [ 184 | 1, 185 | 2, 186 | 3 187 | ] 188 | } 189 | """ 190 | ) 191 | end 192 | 193 | test "sorts sigils" do 194 | assert_style("# styler:sort\n~w|c a b|", "# styler:sort\n~w|a b c|") 195 | 196 | assert_style( 197 | """ 198 | # styler:sort 199 | ~w( 200 | a 201 | long 202 | list 203 | of 204 | static 205 | values 206 | ) 207 | """, 208 | """ 209 | # styler:sort 210 | ~w( 211 | a 212 | list 213 | long 214 | of 215 | static 216 | values 217 | ) 218 | """ 219 | ) 220 | end 221 | 222 | test "assignments" do 223 | assert_style( 224 | """ 225 | # styler:sort 226 | my_var = 227 | ~w( 228 | a 229 | long 230 | list 231 | of 232 | static 233 | values 234 | ) 235 | """, 236 | """ 237 | # styler:sort 238 | my_var = 239 | ~w( 240 | a 241 | list 242 | long 243 | of 244 | static 245 | values 246 | ) 247 | """ 248 | ) 249 | 250 | assert_style( 251 | """ 252 | defmodule M do 253 | @moduledoc false 254 | # styler:sort 255 | @attr ~w( 256 | a 257 | long 258 | list 259 | of 260 | static 261 | values 262 | ) 263 | end 264 | """, 265 | """ 266 | defmodule M do 267 | @moduledoc false 268 | # styler:sort 269 | @attr ~w( 270 | a 271 | list 272 | long 273 | of 274 | static 275 | values 276 | ) 277 | end 278 | """ 279 | ) 280 | end 281 | 282 | test "doesnt affect downstream nodes" do 283 | assert_style( 284 | """ 285 | # styler:sort 286 | [:c, :a, :b] 287 | 288 | @country_codes ~w( 289 | po_PO 290 | en_US 291 | fr_CA 292 | ja_JP 293 | ) 294 | """, 295 | """ 296 | # styler:sort 297 | [:a, :b, :c] 298 | 299 | @country_codes ~w( 300 | po_PO 301 | en_US 302 | fr_CA 303 | ja_JP 304 | ) 305 | """ 306 | ) 307 | end 308 | 309 | test "list of tuples" do 310 | # 2ples are represented as block literals while >2ples are created via `:{}` 311 | # decided the easiest way to handle this is to just use string representation for meow 312 | assert_style( 313 | """ 314 | # styler:sort 315 | [ 316 | {:styler, github: "adobe/elixir-styler"}, 317 | {:ash, "~> 3.0"}, 318 | {:fluxon, "~> 1.0.0", repo: :fluxon}, 319 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 320 | {:tailwind, "~> 0.2", runtime: Mix.env() == :dev} 321 | ] 322 | """, 323 | """ 324 | # styler:sort 325 | [ 326 | {:ash, "~> 3.0"}, 327 | {:fluxon, "~> 1.0.0", repo: :fluxon}, 328 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 329 | {:styler, github: "adobe/elixir-styler"}, 330 | {:tailwind, "~> 0.2", runtime: Mix.env() == :dev} 331 | ] 332 | """ 333 | ) 334 | end 335 | 336 | test "nodes within a do end block" do 337 | assert_style( 338 | """ 339 | # styler:sort 340 | my_macro "some arg" do 341 | another_macro :q 342 | # w 343 | another_macro :w 344 | another_macro :e 345 | # r comment 1 346 | # r comment 2 347 | another_macro :r 348 | another_macro :t 349 | another_macro :y 350 | end 351 | """, 352 | """ 353 | # styler:sort 354 | my_macro "some arg" do 355 | another_macro(:e) 356 | another_macro(:q) 357 | # r comment 1 358 | # r comment 2 359 | another_macro(:r) 360 | another_macro(:t) 361 | # w 362 | another_macro(:w) 363 | another_macro(:y) 364 | end 365 | """ 366 | ) 367 | end 368 | 369 | test "treats comments nicely" do 370 | assert_style( 371 | """ 372 | # pre-amble comment 373 | # styler:sort 374 | [ 375 | {:phoenix, "~> 1.7"}, 376 | # hackney comment 377 | {:hackney, "1.18.1", override: true}, 378 | {:styler, "~> 1.2", only: [:dev, :test], runtime: false}, 379 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, 380 | {:excellent_migrations, "~> 0.1", only: [:dev, :test], runtime: false}, 381 | # ecto 382 | {:ecto, "~> 3.12"}, 383 | {:ecto_sql, "~> 3.12"}, 384 | # genstage comment 1 385 | # genstage comment 2 386 | {:gen_stage, "~> 1.0", override: true}, 387 | # telemetry 388 | {:telemetry, "~> 1.0", override: true}, 389 | # dangling comment 390 | ] 391 | 392 | # some other comment 393 | """, 394 | """ 395 | # pre-amble comment 396 | # styler:sort 397 | [ 398 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, 399 | # ecto 400 | {:ecto, "~> 3.12"}, 401 | {:ecto_sql, "~> 3.12"}, 402 | {:excellent_migrations, "~> 0.1", only: [:dev, :test], runtime: false}, 403 | # genstage comment 1 404 | # genstage comment 2 405 | {:gen_stage, "~> 1.0", override: true}, 406 | # hackney comment 407 | {:hackney, "1.18.1", override: true}, 408 | {:phoenix, "~> 1.7"}, 409 | {:styler, "~> 1.2", only: [:dev, :test], runtime: false}, 410 | # telemetry 411 | {:telemetry, "~> 1.0", override: true} 412 | # dangling comment 413 | ] 414 | 415 | # some other comment 416 | """ 417 | ) 418 | end 419 | end 420 | end 421 | -------------------------------------------------------------------------------- /test/style/configs_test.exs: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Styler.Style.ConfigsTest do 12 | @moduledoc false 13 | use Styler.StyleCase, async: true, filename: "config/config.exs" 14 | 15 | alias Styler.Style.Configs 16 | 17 | test "only runs on exs files in config folders" do 18 | {ast, _} = Styler.string_to_ast("import Config\n\nconfig :bar, boop: :baz") 19 | zipper = Styler.Zipper.zip(ast) 20 | 21 | for file <- ~w(dev.exs my_app.exs config.exs) do 22 | # :config? is private api, so don't be surprised if this has to change at some point 23 | assert {:cont, _, %{config?: true}} = Configs.run(zipper, %{file: "apps/foo/config/#{file}"}) 24 | assert {:cont, _, %{config?: true}} = Configs.run(zipper, %{file: "config/#{file}"}) 25 | assert {:cont, _, %{config?: true}} = Configs.run(zipper, %{file: "rel/overlays/#{file}"}) 26 | assert {:halt, _, _} = Configs.run(zipper, %{file: file}) 27 | end 28 | end 29 | 30 | test "doesn't sort when no import config" do 31 | assert_style """ 32 | config :z, :x, :c 33 | config :a, :b, :c 34 | """ 35 | end 36 | 37 | test "simple case" do 38 | assert_style( 39 | """ 40 | import Config 41 | 42 | config :z, :x, :c 43 | config :a, :b, :c 44 | config :y, :x, :z 45 | config :a, :c, :d 46 | """, 47 | """ 48 | import Config 49 | 50 | config :a, :b, :c 51 | config :a, :c, :d 52 | 53 | config :y, :x, :z 54 | 55 | config :z, :x, :c 56 | """ 57 | ) 58 | end 59 | 60 | test "more complicated" do 61 | assert_style( 62 | """ 63 | import Config 64 | dog_sound = :woof 65 | config :z, :x, dog_sound 66 | 67 | c = :c 68 | config :a, :b, c 69 | config :a, :c, :d 70 | config :a, 71 | a_longer_name: :a_longer_value, 72 | multiple_things: :that_could_all_fit_on_one_line_though 73 | 74 | my_app = 75 | :"dont_write_configs_like_this_yall_:(" 76 | 77 | your_app = :not_again! 78 | config your_app, :dont_use_varrrrrrrrs 79 | config my_app, :nooooooooo 80 | import_config "my_config" 81 | 82 | cat_sound = :meow 83 | config :z, a: :meow 84 | a_sad_overwrite_that_will_be_hard_to_notice = :x 85 | config :a, :b, a_sad_overwrite_that_will_be_hard_to_notice 86 | """, 87 | """ 88 | import Config 89 | 90 | dog_sound = :woof 91 | c = :c 92 | 93 | my_app = 94 | :"dont_write_configs_like_this_yall_:(" 95 | 96 | your_app = :not_again! 97 | 98 | config :a, :b, c 99 | config :a, :c, :d 100 | 101 | config :a, 102 | a_longer_name: :a_longer_value, 103 | multiple_things: :that_could_all_fit_on_one_line_though 104 | 105 | config :z, :x, dog_sound 106 | 107 | config my_app, :nooooooooo 108 | 109 | config your_app, :dont_use_varrrrrrrrs 110 | 111 | import_config "my_config" 112 | 113 | cat_sound = :meow 114 | a_sad_overwrite_that_will_be_hard_to_notice = :x 115 | 116 | config :a, :b, a_sad_overwrite_that_will_be_hard_to_notice 117 | 118 | config :z, a: :meow 119 | """ 120 | ) 121 | end 122 | 123 | test "ignores things that look like config/1" do 124 | assert_style """ 125 | import Config 126 | 127 | config :a, :b 128 | 129 | config(a) 130 | config :c, :d 131 | """ 132 | end 133 | 134 | describe "playing nice with comments" do 135 | test "lets you leave comments in large stanzas" do 136 | assert_style """ 137 | import Config 138 | 139 | config :a, B, :c 140 | 141 | config :a, 142 | b: :c, 143 | # d is here 144 | d: :e 145 | """ 146 | end 147 | 148 | test "simple case" do 149 | assert_style( 150 | """ 151 | import Config 152 | 153 | config :a, 1 154 | config :a, 4 155 | # comment 156 | # b comment 157 | config :b, 1 158 | config :b, 2 159 | config :a, 2 160 | config :a, 3 161 | """, 162 | """ 163 | import Config 164 | 165 | config :a, 1 166 | config :a, 2 167 | config :a, 3 168 | config :a, 4 169 | 170 | # comment 171 | # b comment 172 | config :b, 1 173 | config :b, 2 174 | """ 175 | ) 176 | end 177 | 178 | test "complicated comments" do 179 | assert_style( 180 | """ 181 | import Config 182 | dog_sound = :woof 183 | # z is best when configged w/ dog sounds 184 | # dog sounds ftw 185 | config :z, :x, dog_sound 186 | 187 | # this is my big c 188 | # comment i'd like to leave c 189 | # about c 190 | c = :c 191 | config :a, :b, c 192 | config :a, :c, :d 193 | config :a, 194 | a_longer_name: :a_longer_value, 195 | # Multiline comment 196 | # comment in a block 197 | multiple_things: :that_could_all_fit_on_one_line_though 198 | 199 | # this is my big my_app 200 | # comment i'd like to leave my_app 201 | # about my_app 202 | my_app = 203 | :"dont_write_configs_like_this_yall_:(" 204 | 205 | # this is my big your_app 206 | # comment i'd like to leave your_app 207 | # about your_app 208 | your_app = :not_again! 209 | config your_app, :dont_use_varrrrrrrrs 210 | config my_app, :nooooooooo 211 | import_config "my_config" 212 | 213 | cat_sound = :meow 214 | config :z, a: :meow 215 | a_sad_overwrite_that_will_be_hard_to_notice = :x 216 | config :a, :b, a_sad_overwrite_that_will_be_hard_to_notice 217 | """, 218 | """ 219 | import Config 220 | 221 | dog_sound = :woof 222 | 223 | # this is my big c 224 | # comment i'd like to leave c 225 | # about c 226 | c = :c 227 | 228 | # this is my big my_app 229 | # comment i'd like to leave my_app 230 | # about my_app 231 | my_app = 232 | :"dont_write_configs_like_this_yall_:(" 233 | 234 | # this is my big your_app 235 | # comment i'd like to leave your_app 236 | # about your_app 237 | your_app = :not_again! 238 | 239 | config :a, :b, c 240 | config :a, :c, :d 241 | 242 | config :a, 243 | a_longer_name: :a_longer_value, 244 | # Multiline comment 245 | # comment in a block 246 | multiple_things: :that_could_all_fit_on_one_line_though 247 | 248 | # z is best when configged w/ dog sounds 249 | # dog sounds ftw 250 | config :z, :x, dog_sound 251 | 252 | config my_app, :nooooooooo 253 | 254 | config your_app, :dont_use_varrrrrrrrs 255 | 256 | import_config "my_config" 257 | 258 | cat_sound = :meow 259 | a_sad_overwrite_that_will_be_hard_to_notice = :x 260 | 261 | config :a, :b, a_sad_overwrite_that_will_be_hard_to_notice 262 | 263 | config :z, a: :meow 264 | """ 265 | ) 266 | end 267 | 268 | test "comments, more nuanced" do 269 | assert_style( 270 | """ 271 | # start config 272 | # import 273 | import Config 274 | 275 | # random noise 276 | 277 | config :c, 278 | # ca 279 | ca: :ca, 280 | # cb 1 281 | # cb 2 282 | cb: :cb, 283 | cc: :cc, 284 | # cd 285 | cd: :cd 286 | 287 | # yeehaw 288 | config :b, :yeehaw, :meow 289 | config :b, :apples, :oranges 290 | config :b, 291 | a: :b, 292 | # bcd 293 | c: :d, 294 | e: :f 295 | 296 | # some junk after b, idk 297 | 298 | config :a, 299 | # aa 300 | aa: :aa, 301 | # ab 1 302 | # ab 2 303 | ab: :ab, 304 | ac: :ac, 305 | # ad 306 | ad: :cd 307 | 308 | # end of config 309 | """, 310 | """ 311 | # start config 312 | # import 313 | import Config 314 | 315 | # random noise 316 | 317 | config :a, 318 | # aa 319 | aa: :aa, 320 | # ab 1 321 | # ab 2 322 | ab: :ab, 323 | ac: :ac, 324 | # ad 325 | ad: :cd 326 | 327 | config :b, :apples, :oranges 328 | 329 | # yeehaw 330 | config :b, :yeehaw, :meow 331 | 332 | config :b, 333 | a: :b, 334 | # bcd 335 | c: :d, 336 | e: :f 337 | 338 | # some junk after b, idk 339 | 340 | config :c, 341 | # ca 342 | ca: :ca, 343 | # cb 1 344 | # cb 2 345 | cb: :cb, 346 | cc: :cc, 347 | # cd 348 | cd: :cd 349 | 350 | # end of config 351 | """ 352 | ) 353 | end 354 | 355 | test "big block regression #230" do 356 | # The nodes are in reverse order 357 | assert_style( 358 | """ 359 | import Config 360 | 361 | # z-a 362 | # z-b 363 | # z-c 364 | # z-d 365 | # z-e 366 | config :z, z 367 | 368 | # y 369 | config :y, y 370 | 371 | # x 372 | config :x, x 373 | """, 374 | """ 375 | import Config 376 | 377 | # x 378 | config :x, x 379 | 380 | # y 381 | config :y, y 382 | 383 | # z-a 384 | # z-b 385 | # z-c 386 | # z-d 387 | # z-e 388 | config :z, z 389 | """ 390 | ) 391 | end 392 | end 393 | end 394 | -------------------------------------------------------------------------------- /test/style/defs_test.exs: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Styler.Style.DefsTest do 12 | use Styler.StyleCase, async: true 13 | 14 | test "comments stay put when we can't shrink the head, even with blocks" do 15 | assert_style(""" 16 | def my_function( 17 | so_long_that_this_head_will_not_fit_on_one_lineso_long_that_this_head_will_not_fit_on_one_line, 18 | so_long_that_this_head_will_not_fit_on_one_line 19 | ) do 20 | result = 21 | case foo do 22 | :bar -> :baz 23 | :baz -> :bong 24 | end 25 | 26 | # My comment 27 | Context.process(result) 28 | end 29 | """) 30 | end 31 | 32 | test "function with do keyword" do 33 | assert_style( 34 | """ 35 | # Top comment 36 | def save( 37 | # Socket comment 38 | %Socket{assigns: %{user: user, live_action: :new}} = initial_socket, 39 | # Params comment 40 | params 41 | ), 42 | do: :ok 43 | """, 44 | """ 45 | # Top comment 46 | # Socket comment 47 | # Params comment 48 | def save(%Socket{assigns: %{user: user, live_action: :new}} = initial_socket, params), do: :ok 49 | """ 50 | ) 51 | end 52 | 53 | test "bodyless function with spec" do 54 | assert_style(""" 55 | @spec original_object(atom()) :: atom() 56 | def original_object(object) 57 | """) 58 | end 59 | 60 | test "block function body doesn't get newlined" do 61 | assert_style(""" 62 | # Here's a comment 63 | def some_function(%{id: id, type: type, processed_at: processed_at} = file, params, _) 64 | when type == :file and is_nil(processed_at) do 65 | with {:ok, results} <- FileProcessor.process(file) do 66 | # This comment could make sense 67 | {:ok, post_process_the_results_somehow(results)} 68 | end 69 | end 70 | """) 71 | end 72 | 73 | test "kwl function body doesn't get newlined" do 74 | assert_style(""" 75 | def is_expired_timestamp?(timestamp) when is_integer(timestamp), 76 | do: Timex.from_unix(timestamp, :second) <= Timex.shift(DateTime.utc_now(), minutes: 1) 77 | """) 78 | end 79 | 80 | test "function with do block" do 81 | assert_style( 82 | """ 83 | def save( 84 | %Socket{assigns: %{user: user, live_action: :new}} = initial_socket, 85 | params # Comments in the darndest places 86 | ) do 87 | :ok 88 | end 89 | """, 90 | """ 91 | # Comments in the darndest places 92 | def save(%Socket{assigns: %{user: user, live_action: :new}} = initial_socket, params) do 93 | :ok 94 | end 95 | """ 96 | ) 97 | end 98 | 99 | test "no body" do 100 | assert_style "def no_body_nor_parens_yikes!" 101 | 102 | assert_style( 103 | """ 104 | # Top comment 105 | def no_body( 106 | foo, # This is a foo 107 | bar # This is a bar 108 | ) 109 | 110 | # Another comment for this head 111 | def no_body(nil, _), do: nil 112 | """, 113 | """ 114 | # Top comment 115 | # This is a foo 116 | # This is a bar 117 | def no_body(foo, bar) 118 | 119 | # Another comment for this head 120 | def no_body(nil, _), do: nil 121 | """ 122 | ) 123 | end 124 | 125 | test "when clause w kwl do" do 126 | assert_style( 127 | """ 128 | def foo(%{ 129 | bar: baz 130 | }) 131 | # Self-documenting code! 132 | when baz in [ 133 | :a, # Obviously, this is a 134 | :b # ... and this is b 135 | ], 136 | do: :never_write_code_like_this 137 | """, 138 | """ 139 | # Self-documenting code! 140 | # Obviously, this is a 141 | # ... and this is b 142 | def foo(%{bar: baz}) when baz in [:a, :b], do: :never_write_code_like_this 143 | """ 144 | ) 145 | end 146 | 147 | test "keyword do with a list" do 148 | assert_style( 149 | """ 150 | def foo, 151 | do: [ 152 | # Weirdo comment 153 | :never_write_code_like_this 154 | ] 155 | """, 156 | """ 157 | # Weirdo comment 158 | def foo, do: [:never_write_code_like_this] 159 | """ 160 | ) 161 | end 162 | 163 | test "rewrites subsequent definitions" do 164 | assert_style( 165 | """ 166 | def foo(), do: :ok 167 | 168 | def foo( 169 | too, 170 | # Long long is too long 171 | long 172 | ), do: :ok 173 | """, 174 | """ 175 | def foo, do: :ok 176 | 177 | # Long long is too long 178 | def foo(too, long), do: :ok 179 | """ 180 | ) 181 | end 182 | 183 | test "when clause with block do" do 184 | assert_style( 185 | """ 186 | # Foo takes a bar 187 | def foo(%{ 188 | bar: baz 189 | }) 190 | # Baz should be either :a or :b 191 | when baz in [ 192 | :a, 193 | :b 194 | ] 195 | do # Weird place for a comment 196 | # Above the body 197 | :never_write_code_like_this 198 | # Below the body 199 | end 200 | """, 201 | """ 202 | # Foo takes a bar 203 | # Baz should be either :a or :b 204 | # Weird place for a comment 205 | def foo(%{bar: baz}) when baz in [:a, :b] do 206 | # Above the body 207 | :never_write_code_like_this 208 | # Below the body 209 | end 210 | """ 211 | ) 212 | end 213 | 214 | test "Doesn't move stuff around if it would make the line too long" do 215 | assert_style(""" 216 | @doc "this is a doc" 217 | # And also a comment 218 | def wow_this_function_name_is_super_long(it_also, has_a, ton_of, arguments), 219 | do: "this is going to end up making the line too long if we inline it" 220 | 221 | @doc "this is another function" 222 | # And it also has a comment 223 | def this_one_fits_on_one_line, do: :ok 224 | """) 225 | end 226 | 227 | test "Doesn't collapse pipe chains in a def do ... end" do 228 | assert_style(""" 229 | def foo(some_list) do 230 | some_list 231 | |> Enum.reject(&is_nil/1) 232 | |> Enum.map(&transform/1) 233 | end 234 | """) 235 | end 236 | 237 | describe "no ops" do 238 | test "regression: @def module attribute" do 239 | assert_style("@def ~s(this should be okay)") 240 | end 241 | 242 | test "no explode on invalid def syntax" do 243 | assert_style("def foo, true") 244 | assert_style("def foo(a), true") 245 | assert_raise SyntaxError, fn -> assert_style("def foo(a) true") end 246 | end 247 | end 248 | end 249 | -------------------------------------------------------------------------------- /test/style/deprecations_test.exs: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Styler.Style.DeprecationsTest do 12 | use Styler.StyleCase, async: true 13 | 14 | test "Logger.warn to Logger.warning" do 15 | assert_style("Logger.warn(foo)", "Logger.warning(foo)") 16 | assert_style("Logger.warn(foo, bar)", "Logger.warning(foo, bar)") 17 | end 18 | 19 | test "Path.safe_relative_to/2 to Path.safe_relative/2" do 20 | assert_style("Path.safe_relative_to(foo, bar)", "Path.safe_relative(foo, bar)") 21 | 22 | assert_style( 23 | """ 24 | "FOO" 25 | |> String.downcase() 26 | |> Path.safe_relative_to("/") 27 | """, 28 | """ 29 | "FOO" 30 | |> String.downcase() 31 | |> Path.safe_relative("/") 32 | """ 33 | ) 34 | end 35 | 36 | test "matching ranges" do 37 | assert_style "first..last = range", "first..last//_ = range" 38 | assert_style "^first..^last = range", "^first..^last//_ = range" 39 | assert_style "first..last = x = y", "first..last//_ = x = y" 40 | assert_style "y = first..last = x", "y = first..last//_ = x" 41 | 42 | assert_style "def foo(x..y), do: :ok", "def foo(x..y//_), do: :ok" 43 | assert_style "def foo(a, x..y = z), do: :ok", "def foo(a, x..y//_ = z), do: :ok" 44 | assert_style "def foo(%{a: x..y = z}), do: :ok", "def foo(%{a: x..y//_ = z}), do: :ok" 45 | 46 | assert_style "with a..b = c <- :ok, d..e <- :better, do: :ok", "with a..b//_ = c <- :ok, d..e//_ <- :better, do: :ok" 47 | 48 | assert_style( 49 | """ 50 | case x do 51 | a..b = c -> :ok 52 | d..e -> :better 53 | end 54 | """, 55 | """ 56 | case x do 57 | a..b//_ = c -> :ok 58 | d..e//_ -> :better 59 | end 60 | """ 61 | ) 62 | end 63 | 64 | test "List.zip/1" do 65 | assert_style "List.zip(foo)", "Enum.zip(foo)" 66 | assert_style "foo |> List.zip |> bar", "foo |> Enum.zip() |> bar()" 67 | assert_style "foo |> List.zip", "Enum.zip(foo)" 68 | end 69 | 70 | test "~R is deprecated in favor of ~r" do 71 | assert_style(~s|Regex.match?(~R/foo/, "foo")|, ~s|Regex.match?(~r/foo/, "foo")|) 72 | end 73 | 74 | test "replace Date.range/2 with Date.range/3 when first > last" do 75 | assert_style("Date.range(~D[2000-01-01], ~D[1999-01-01])", "Date.range(~D[2000-01-01], ~D[1999-01-01], -1)") 76 | 77 | assert_style( 78 | "~D[2000-01-01] |> Date.range(~D[1999-01-01]) |> foo()", 79 | "~D[2000-01-01] |> Date.range(~D[1999-01-01], -1) |> foo()" 80 | ) 81 | 82 | assert_style("Date.range(~D[1999-01-01], ~D[2000-01-01])") 83 | assert_style("Date.range(~D[1999-01-01], ~D[1999-01-01])") 84 | end 85 | 86 | test "use :eof instead of :all in IO.read/2 and IO.binread/2" do 87 | assert_style("IO.read(:all)", "IO.read(:eof)") 88 | assert_style("IO.read(device, :all)", "IO.read(device, :eof)") 89 | assert_style("IO.binread(:all)", "IO.binread(:eof)") 90 | assert_style("IO.binread(device, :all)", "IO.binread(device, :eof)") 91 | 92 | assert_style( 93 | "file |> IO.binread(:all) |> :binary.bin_to_list()", 94 | "file |> IO.binread(:eof) |> :binary.bin_to_list()" 95 | ) 96 | end 97 | 98 | test "negative steps with [Enum|String].slice/2" do 99 | for mod <- ~w(Enum String) do 100 | assert_style("#{mod}.slice(x, 1..-2)", "#{mod}.slice(x, 1..-2//1)") 101 | assert_style("#{mod}.slice(x, -1..-2)", "#{mod}.slice(x, -1..-2//1)") 102 | assert_style("#{mod}.slice(x, 2..1)", "#{mod}.slice(x, 2..1//1)") 103 | assert_style("#{mod}.slice(x, 1..3)") 104 | assert_style("#{mod}.slice(x, ..)") 105 | 106 | # piped 107 | assert_style("foo |> bar() |> #{mod}.slice(1..-2)", "foo |> bar() |> #{mod}.slice(1..-2//1)") 108 | assert_style("foo |> bar() |> #{mod}.slice(-1..-2)", "foo |> bar() |> #{mod}.slice(-1..-2//1)") 109 | assert_style("foo |> bar() |> #{mod}.slice(2..1)", "foo |> bar() |> #{mod}.slice(2..1//1)") 110 | assert_style("foo |> bar() |> #{mod}.slice(1..3)") 111 | 112 | # non-trivial ranges 113 | assert_style "#{mod}.slice(x, y..z)" 114 | assert_style "#{mod}.slice(x, (y - 1)..f)" 115 | assert_style("foo |> bar() |> #{mod}.slice(x..y)") 116 | end 117 | end 118 | 119 | test "struct update, deprecated in 1.19" do 120 | assert_style "%Foo{widget | bar: :baz}", "%{widget | bar: :baz}" 121 | end 122 | 123 | describe "1.16+" do 124 | @describetag skip: Version.match?(System.version(), "< 1.16.0-dev") 125 | 126 | test "File.stream!(path, modes, line_or_bytes) to File.stream!(path, line_or_bytes, modes)" do 127 | assert_style( 128 | "File.stream!(path, [encoding: :utf8, trim_bom: true], :line)", 129 | "File.stream!(path, :line, encoding: :utf8, trim_bom: true)" 130 | ) 131 | 132 | assert_style( 133 | "f |> File.stream!([encoding: :utf8, trim_bom: true], :line) |> Enum.take(2)", 134 | "f |> File.stream!(:line, encoding: :utf8, trim_bom: true) |> Enum.take(2)" 135 | ) 136 | end 137 | end 138 | 139 | describe "1.17+" do 140 | @describetag skip: Version.match?(System.version(), "< 1.17.0-dev") 141 | 142 | test "to_timeout/1 vs :timer.units(x)" do 143 | assert_style ":timer.hours(x)", "to_timeout(hour: x)" 144 | assert_style ":timer.minutes(x)", "to_timeout(minute: x)" 145 | assert_style ":timer.seconds(x)", "to_timeout(second: x)" 146 | 147 | assert_style "a |> x() |> :timer.hours()" 148 | assert_style "a |> x() |> :timer.minutes()" 149 | assert_style "a |> x() |> :timer.seconds()" 150 | end 151 | 152 | test "combined with to_timeout improvements" do 153 | assert_style ":timer.minutes(60 * 4)", "to_timeout(hour: 4)" 154 | end 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /test/style/module_directives/alias_lifting_test.exs: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Styler.Style.ModuleDirectives.AliasLiftingTest do 12 | @moduledoc false 13 | use Styler.StyleCase, async: true 14 | 15 | test "lifts aliases repeated >=2 times from 3 deep" do 16 | assert_style( 17 | """ 18 | defmodule A do 19 | @moduledoc false 20 | 21 | @spec bar :: A.B.C.t() 22 | def bar do 23 | A.B.C.f() 24 | end 25 | end 26 | """, 27 | """ 28 | defmodule A do 29 | @moduledoc false 30 | 31 | alias A.B.C 32 | 33 | @spec bar :: C.t() 34 | def bar do 35 | C.f() 36 | end 37 | end 38 | """ 39 | ) 40 | end 41 | 42 | test "lifts from nested modules" do 43 | assert_style( 44 | """ 45 | defmodule A do 46 | @moduledoc false 47 | 48 | defmodule B do 49 | @moduledoc false 50 | 51 | A.B.C.f() 52 | A.B.C.f() 53 | end 54 | end 55 | """, 56 | """ 57 | defmodule A do 58 | @moduledoc false 59 | 60 | alias A.B.C 61 | 62 | defmodule B do 63 | @moduledoc false 64 | 65 | C.f() 66 | C.f() 67 | end 68 | end 69 | """ 70 | ) 71 | 72 | # this isn't exactly _desired_ behaviour but i don't see a real problem with it. 73 | # as long as we're deterministic that's alright. this... really should never happen in the real world. 74 | assert_style( 75 | """ 76 | defmodule A do 77 | defmodule B do 78 | A.B.C.f() 79 | A.B.C.f() 80 | end 81 | end 82 | """, 83 | """ 84 | defmodule A do 85 | @moduledoc false 86 | alias A.B.C 87 | 88 | defmodule B do 89 | @moduledoc false 90 | C.f() 91 | C.f() 92 | end 93 | end 94 | """ 95 | ) 96 | end 97 | 98 | test "only deploys new aliases in nodes _after_ the alias stanza" do 99 | assert_style( 100 | """ 101 | defmodule Timely do 102 | use A.B.C 103 | def foo do 104 | A.B.C.bop 105 | end 106 | import A.B.C 107 | require A.B.C 108 | end 109 | """, 110 | """ 111 | defmodule Timely do 112 | @moduledoc false 113 | use A.B.C 114 | 115 | import A.B.C 116 | 117 | alias A.B.C 118 | 119 | require C 120 | 121 | def foo do 122 | C.bop() 123 | end 124 | end 125 | """ 126 | ) 127 | end 128 | 129 | test "skips over quoted or odd aliases" do 130 | assert_style """ 131 | alias Boop.Baz 132 | 133 | Some.unquote(whatever).Alias.bar() 134 | Some.unquote(whatever).Alias.bar() 135 | """ 136 | end 137 | 138 | test "deep nesting of an alias" do 139 | assert_style( 140 | """ 141 | alias Foo.Bar.Baz 142 | 143 | Baz.Bop.Boom.wee() 144 | Baz.Bop.Boom.wee() 145 | 146 | """, 147 | """ 148 | alias Foo.Bar.Baz 149 | alias Foo.Bar.Baz.Bop.Boom 150 | 151 | Boom.wee() 152 | Boom.wee() 153 | """ 154 | ) 155 | end 156 | 157 | test "lifts in modules with only-child bodies" do 158 | assert_style( 159 | """ 160 | defmodule A do 161 | def lift_me() do 162 | A.B.C.foo() 163 | A.B.C.baz() 164 | end 165 | end 166 | """, 167 | """ 168 | defmodule A do 169 | @moduledoc false 170 | alias A.B.C 171 | 172 | def lift_me do 173 | C.foo() 174 | C.baz() 175 | end 176 | end 177 | """ 178 | ) 179 | end 180 | 181 | test "re-sorts requires after lifting" do 182 | assert_style( 183 | """ 184 | defmodule A do 185 | require A.B.C 186 | require B 187 | 188 | A.B.C.foo() 189 | end 190 | """, 191 | """ 192 | defmodule A do 193 | @moduledoc false 194 | alias A.B.C 195 | 196 | require B 197 | require C 198 | 199 | C.foo() 200 | end 201 | """ 202 | ) 203 | end 204 | 205 | test "replaces known aliases" do 206 | assert_style( 207 | """ 208 | alias A.B.C 209 | 210 | A.B.C.foo() 211 | A.B.C.foo() 212 | A.B.C.foo() 213 | """, 214 | """ 215 | alias A.B.C 216 | 217 | C.foo() 218 | C.foo() 219 | C.foo() 220 | """ 221 | ) 222 | end 223 | 224 | test "two modules that seem to conflict but don't!" do 225 | assert_style( 226 | """ 227 | defmodule Foo do 228 | @moduledoc false 229 | 230 | A.B.C.foo(X.Y.A) 231 | A.B.C.bar() 232 | 233 | X.Y.A 234 | end 235 | """, 236 | """ 237 | defmodule Foo do 238 | @moduledoc false 239 | 240 | alias A.B.C 241 | alias X.Y.A 242 | 243 | C.foo(A) 244 | C.bar() 245 | 246 | A 247 | end 248 | """ 249 | ) 250 | end 251 | 252 | test "if multiple lifts collide, lifts only one" do 253 | assert_style( 254 | """ 255 | defmodule Foo do 256 | @moduledoc false 257 | 258 | A.B.C.f() 259 | A.B.C.f() 260 | X.Y.C.f() 261 | end 262 | """, 263 | """ 264 | defmodule Foo do 265 | @moduledoc false 266 | 267 | alias A.B.C 268 | 269 | C.f() 270 | C.f() 271 | X.Y.C.f() 272 | end 273 | """ 274 | ) 275 | 276 | assert_style( 277 | """ 278 | defmodule Foo do 279 | @moduledoc false 280 | 281 | A.B.C.f() 282 | X.Y.C.f() 283 | X.Y.C.f() 284 | A.B.C.f() 285 | end 286 | """, 287 | """ 288 | defmodule Foo do 289 | @moduledoc false 290 | 291 | alias A.B.C 292 | 293 | C.f() 294 | X.Y.C.f() 295 | X.Y.C.f() 296 | C.f() 297 | end 298 | """ 299 | ) 300 | 301 | assert_style( 302 | """ 303 | defmodule Foo do 304 | @moduledoc false 305 | 306 | X.Y.C.f() 307 | A.B.C.f() 308 | X.Y.C.f() 309 | A.B.C.f() 310 | end 311 | """, 312 | """ 313 | defmodule Foo do 314 | @moduledoc false 315 | 316 | alias X.Y.C 317 | 318 | C.f() 319 | A.B.C.f() 320 | C.f() 321 | A.B.C.f() 322 | end 323 | """ 324 | ) 325 | end 326 | 327 | describe "comments stay put" do 328 | test "comments before alias stanza" do 329 | assert_style( 330 | """ 331 | # Foo is my fave 332 | import Foo 333 | 334 | A.B.C.f() 335 | A.B.C.f() 336 | """, 337 | """ 338 | # Foo is my fave 339 | import Foo 340 | 341 | alias A.B.C 342 | 343 | C.f() 344 | C.f() 345 | """ 346 | ) 347 | end 348 | 349 | test "comments after alias stanza" do 350 | assert_style( 351 | """ 352 | # Foo is my fave 353 | require Foo 354 | 355 | A.B.C.f() 356 | A.B.C.f() 357 | """, 358 | """ 359 | alias A.B.C 360 | # Foo is my fave 361 | require Foo 362 | 363 | C.f() 364 | C.f() 365 | """ 366 | ) 367 | end 368 | end 369 | 370 | describe "it doesn't lift" do 371 | test "collisions with configured modules" do 372 | Styler.Config.set!(alias_lifting_exclude: ~w(C)a) 373 | 374 | assert_style """ 375 | alias Foo.Bar 376 | 377 | A.B.C 378 | A.B.C 379 | """ 380 | 381 | Styler.Config.set!([]) 382 | end 383 | 384 | test "collisions with std lib" do 385 | assert_style """ 386 | defmodule DontYouDare do 387 | @moduledoc false 388 | 389 | My.Sweet.List.foo() 390 | My.Sweet.List.foo() 391 | IHave.MyOwn.Supervisor.init() 392 | IHave.MyOwn.Supervisor.init() 393 | end 394 | """ 395 | end 396 | 397 | test "collisions with aliases" do 398 | for alias_c <- ["alias A.C", "alias A.B, as: C"] do 399 | assert_style """ 400 | defmodule NuhUh do 401 | @moduledoc false 402 | 403 | #{alias_c} 404 | 405 | A.B.C.f() 406 | A.B.C.f() 407 | end 408 | """ 409 | end 410 | end 411 | 412 | test "collisions with submodules" do 413 | assert_style """ 414 | defmodule A do 415 | @moduledoc false 416 | 417 | A.B.C.f() 418 | 419 | defmodule C do 420 | @moduledoc false 421 | A.B.C.f() 422 | end 423 | 424 | A.B.C.f() 425 | end 426 | """ 427 | end 428 | 429 | test "collisions with 3-deep one-off" do 430 | assert_style """ 431 | defmodule Foo do 432 | @moduledoc false 433 | 434 | X.Y.Z.foo(A.B.X) 435 | 436 | A.B.X 437 | end 438 | """ 439 | end 440 | 441 | test "when new alias being sorted in would change an existing alias" do 442 | assert_style( 443 | """ 444 | defmodule Foo do 445 | @moduledoc false 446 | 447 | X.Y.Z.foo(A.B.X) 448 | X.Y.Z.bar() 449 | 450 | A.B.X 451 | end 452 | """, 453 | """ 454 | defmodule Foo do 455 | @moduledoc false 456 | 457 | alias X.Y.Z 458 | 459 | Z.foo(A.B.X) 460 | Z.bar() 461 | 462 | A.B.X 463 | end 464 | """ 465 | ) 466 | end 467 | 468 | test "defprotocol, defmodule, or defimpl" do 469 | assert_style """ 470 | defmodule No do 471 | @moduledoc false 472 | 473 | defprotocol A.B.C do 474 | :body 475 | end 476 | 477 | A.B.C.f() 478 | end 479 | """ 480 | 481 | assert_style( 482 | """ 483 | defmodule No do 484 | @moduledoc false 485 | 486 | defimpl A.B.C, for: A.B.C do 487 | :body 488 | end 489 | 490 | A.B.C.f() 491 | A.B.C.f() 492 | end 493 | """, 494 | """ 495 | defmodule No do 496 | @moduledoc false 497 | 498 | alias A.B.C 499 | 500 | defimpl A.B.C, for: A.B.C do 501 | :body 502 | end 503 | 504 | C.f() 505 | C.f() 506 | end 507 | """ 508 | ) 509 | 510 | assert_style """ 511 | defmodule No do 512 | @moduledoc false 513 | 514 | defmodule A.B.C do 515 | @moduledoc false 516 | :body 517 | end 518 | 519 | A.B.C.f() 520 | end 521 | """ 522 | 523 | assert_style """ 524 | defmodule No do 525 | @moduledoc false 526 | 527 | defimpl A.B.C, for: A.B.C do 528 | :body 529 | end 530 | 531 | A.B.C.f() 532 | end 533 | """ 534 | end 535 | 536 | test "quoted sections" do 537 | assert_style """ 538 | defmodule A do 539 | @moduledoc false 540 | defmacro __using__(_) do 541 | quote do 542 | A.B.C.f() 543 | A.B.C.f() 544 | end 545 | end 546 | end 547 | """ 548 | end 549 | 550 | test "collisions with other callsites :(" do 551 | # if the last module of a list in an alias 552 | # is the first of any other 553 | # do not do the lift of either? 554 | assert_style """ 555 | defmodule A do 556 | @moduledoc false 557 | 558 | foo 559 | |> Baz.Boom.bop() 560 | |> boop() 561 | 562 | Foo.Bar.Baz.bop() 563 | Foo.Bar.Baz.bop() 564 | end 565 | """ 566 | 567 | assert_style """ 568 | defmodule A do 569 | @moduledoc false 570 | 571 | Foo.Bar.Baz.bop() 572 | Foo.Bar.Baz.bop() 573 | 574 | foo 575 | |> Baz.Boom.bop() 576 | |> boop() 577 | end 578 | """ 579 | end 580 | end 581 | end 582 | -------------------------------------------------------------------------------- /test/style/module_directives_test.exs: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Styler.Style.ModuleDirectivesTest do 12 | @moduledoc false 13 | use Styler.StyleCase, async: true 14 | 15 | describe "defmodule features" do 16 | test "handles module with no directives" do 17 | assert_style(""" 18 | defmodule Test do 19 | def foo, do: :ok 20 | end 21 | """) 22 | end 23 | 24 | test "handles dynamically generated modules" do 25 | assert_style(""" 26 | Enum.each(testing_list, fn test_item -> 27 | defmodule test_item do 28 | end 29 | end) 30 | """) 31 | end 32 | 33 | test "module with single child" do 34 | assert_style( 35 | """ 36 | defmodule ATest do 37 | alias Foo.{A, B} 38 | end 39 | """, 40 | """ 41 | defmodule ATest do 42 | alias Foo.A 43 | alias Foo.B 44 | end 45 | """ 46 | ) 47 | end 48 | 49 | test "adds moduledoc" do 50 | assert_style( 51 | """ 52 | defmodule A do 53 | end 54 | """, 55 | """ 56 | defmodule A do 57 | @moduledoc false 58 | end 59 | """ 60 | ) 61 | 62 | assert_style( 63 | """ 64 | defmodule B do 65 | defmodule C do 66 | end 67 | end 68 | """, 69 | """ 70 | defmodule B do 71 | @moduledoc false 72 | defmodule C do 73 | @moduledoc false 74 | end 75 | end 76 | """ 77 | ) 78 | 79 | assert_style( 80 | """ 81 | defmodule Bar do 82 | alias Bop.Bop 83 | 84 | :ok 85 | end 86 | """, 87 | """ 88 | defmodule Bar do 89 | @moduledoc false 90 | alias Bop.Bop 91 | 92 | :ok 93 | end 94 | """ 95 | ) 96 | 97 | assert_style( 98 | """ 99 | defmodule DocsOnly do 100 | @moduledoc "woohoo" 101 | end 102 | """, 103 | """ 104 | defmodule DocsOnly do 105 | @moduledoc "woohoo" 106 | end 107 | """ 108 | ) 109 | 110 | assert_style( 111 | """ 112 | defmodule Foo do 113 | use Bar 114 | end 115 | """, 116 | """ 117 | defmodule Foo do 118 | @moduledoc false 119 | use Bar 120 | end 121 | """ 122 | ) 123 | 124 | assert_style( 125 | """ 126 | defmodule Foo do 127 | alias Foo.{Bar, Baz} 128 | end 129 | """, 130 | """ 131 | defmodule Foo do 132 | @moduledoc false 133 | alias Foo.Bar 134 | alias Foo.Baz 135 | end 136 | """ 137 | ) 138 | 139 | assert_style( 140 | """ 141 | defmodule A do 142 | defmodule B do 143 | :literal 144 | end 145 | 146 | end 147 | """, 148 | """ 149 | defmodule A do 150 | @moduledoc false 151 | defmodule B do 152 | @moduledoc false 153 | :literal 154 | end 155 | end 156 | """ 157 | ) 158 | end 159 | 160 | test "skips keyword defmodules" do 161 | assert_style("defmodule Foo, do: use(Bar)") 162 | end 163 | 164 | test "doesn't add moduledoc to modules of specific names" do 165 | for verboten <- ~w(Test Mixfile Controller Endpoint Repo Router Socket View HTML JSON) do 166 | assert_style(""" 167 | defmodule A.B.C#{verboten} do 168 | @shortdoc "Don't change me!" 169 | end 170 | """) 171 | end 172 | end 173 | 174 | test "groups directives in order" do 175 | assert_style( 176 | """ 177 | defmodule Foo do 178 | @behaviour Lawful 179 | require A 180 | alias A.A 181 | 182 | use B 183 | 184 | def c(x), do: y 185 | 186 | import C 187 | @behaviour Chaotic 188 | @doc "d doc" 189 | def d do 190 | alias X.X 191 | alias H.H 192 | 193 | alias Z.Z 194 | import Ecto.Query 195 | X.foo() 196 | end 197 | @shortdoc "it's pretty short" 198 | import A 199 | alias C.C 200 | alias D.D 201 | 202 | require C 203 | require B 204 | 205 | use A 206 | 207 | alias C.C 208 | alias A.A 209 | 210 | @moduledoc "README.md" 211 | |> File.read!() 212 | |> String.split("") 213 | |> Enum.fetch!(1) 214 | end 215 | """, 216 | """ 217 | defmodule Foo do 218 | @shortdoc "it's pretty short" 219 | @moduledoc "README.md" 220 | |> File.read!() 221 | |> String.split("") 222 | |> Enum.fetch!(1) 223 | @behaviour Chaotic 224 | @behaviour Lawful 225 | 226 | use B 227 | use A.A 228 | 229 | import A.A 230 | import C 231 | 232 | alias A.A 233 | alias C.C 234 | alias D.D 235 | 236 | require A 237 | require B 238 | require C 239 | 240 | def c(x), do: y 241 | 242 | @doc "d doc" 243 | def d do 244 | import Ecto.Query 245 | 246 | alias H.H 247 | alias X.X 248 | alias Z.Z 249 | 250 | X.foo() 251 | end 252 | end 253 | """ 254 | ) 255 | end 256 | end 257 | 258 | describe "strange parents!" do 259 | test "regression: only triggers on SpecialForms, ignoring functions and vars" do 260 | assert_style("def foo(alias), do: Foo.bar(alias)") 261 | 262 | assert_style(""" 263 | defmodule Foo do 264 | @moduledoc false 265 | @spec import(any(), any(), any()) :: any() 266 | def import(a, b, c), do: nil 267 | end 268 | """) 269 | end 270 | 271 | test "anonymous function" do 272 | assert_style("fn -> alias A.{C, B} end", """ 273 | fn -> 274 | alias A.B 275 | alias A.C 276 | end 277 | """) 278 | end 279 | 280 | test "quote do with one child" do 281 | assert_style( 282 | """ 283 | quote do 284 | alias A.{C, B} 285 | end 286 | """, 287 | """ 288 | quote do 289 | alias A.B 290 | alias A.C 291 | end 292 | """ 293 | ) 294 | end 295 | 296 | test "quote do with multiple children" do 297 | assert_style(""" 298 | quote do 299 | import A 300 | import B 301 | end 302 | """) 303 | end 304 | end 305 | 306 | describe "directive sort/dedupe/expansion" do 307 | test "isn't fooled by function names" do 308 | assert_style( 309 | """ 310 | def import(foo) do 311 | import B 312 | 313 | import A 314 | end 315 | """, 316 | """ 317 | def import(foo) do 318 | import A 319 | import B 320 | end 321 | """ 322 | ) 323 | end 324 | 325 | test "handles a lonely lonely directive" do 326 | assert_style("import Foo") 327 | end 328 | 329 | test "sorts, dedupes & expands alias/require/import while respecting groups" do 330 | for d <- ~w(alias require import) do 331 | assert_style( 332 | """ 333 | #{d} D.D 334 | #{d} A.{B} 335 | #{d} A.{ 336 | A.A, 337 | B, 338 | C 339 | } 340 | #{d} A.B 341 | 342 | #{d} B.B 343 | #{d} A.A 344 | """, 345 | """ 346 | #{d} A.A 347 | #{d} A.A.A 348 | #{d} A.B 349 | #{d} A.C 350 | #{d} B.B 351 | #{d} D.D 352 | """ 353 | ) 354 | end 355 | end 356 | 357 | test "expands __MODULE__" do 358 | assert_style( 359 | """ 360 | alias __MODULE__.{B.D, A} 361 | """, 362 | """ 363 | alias __MODULE__.A 364 | alias __MODULE__.B.D 365 | """ 366 | ) 367 | end 368 | 369 | test "expands use but does not sort it" do 370 | assert_style( 371 | """ 372 | use D 373 | use A 374 | use A.{ 375 | C, 376 | B 377 | } 378 | import F 379 | """, 380 | """ 381 | use D 382 | use A 383 | use A.C 384 | use A.B 385 | 386 | import F 387 | """ 388 | ) 389 | end 390 | 391 | test "interwoven directives w/o the context of a module" do 392 | assert_style( 393 | """ 394 | @type foo :: :ok 395 | alias D.D 396 | alias A.{B} 397 | require A.{ 398 | A, 399 | C 400 | } 401 | alias B.B 402 | alias A.A 403 | """, 404 | """ 405 | alias A.A 406 | alias A.B 407 | alias B.B 408 | alias D.D 409 | 410 | require A.A 411 | require A.C 412 | 413 | @type foo :: :ok 414 | """ 415 | ) 416 | end 417 | 418 | test "respects as" do 419 | assert_style(""" 420 | alias Foo.Asset 421 | alias Foo.Project.Loaders, as: ProjectLoaders 422 | alias Foo.ProjectDevice.Loaders, as: ProjectDeviceLoaders 423 | alias Foo.User.Loaders 424 | """) 425 | end 426 | end 427 | 428 | describe "with comments..." do 429 | test "moving aliases up through non-directives doesn't move comments up" do 430 | assert_style( 431 | """ 432 | defmodule Foo do 433 | # mdf 434 | @moduledoc false 435 | # B 436 | alias B.B 437 | 438 | # foo 439 | def foo do 440 | # ok 441 | :ok 442 | end 443 | # C 444 | alias C.C 445 | # A 446 | alias A.A 447 | end 448 | """, 449 | """ 450 | defmodule Foo do 451 | # mdf 452 | @moduledoc false 453 | alias A.A 454 | # B 455 | alias B.B 456 | alias C.C 457 | 458 | # foo 459 | def foo do 460 | # ok 461 | :ok 462 | end 463 | 464 | # C 465 | # A 466 | end 467 | """ 468 | ) 469 | end 470 | end 471 | 472 | test "Deletes root level alias" do 473 | assert_style("alias Foo", "") 474 | 475 | assert_style( 476 | """ 477 | alias Foo 478 | 479 | Foo.bar() 480 | """, 481 | "Foo.bar()" 482 | ) 483 | 484 | assert_style( 485 | """ 486 | alias unquote(Foo) 487 | alias Foo 488 | alias Bar, as: Bop 489 | alias __MODULE__ 490 | """, 491 | """ 492 | alias __MODULE__ 493 | alias Bar, as: Bop 494 | alias unquote(Foo) 495 | """ 496 | ) 497 | 498 | assert_style( 499 | """ 500 | alias A.A 501 | alias B.B 502 | alias C 503 | 504 | require D 505 | """, 506 | """ 507 | alias A.A 508 | alias B.B 509 | 510 | require D 511 | """ 512 | ) 513 | end 514 | 515 | test "@derive movements" do 516 | assert_style( 517 | """ 518 | defmodule F do 519 | defstruct [:a] 520 | # comment for foo 521 | def foo, do: :ok 522 | @derive Inspect 523 | @derive {Foo, bar: :baz} 524 | end 525 | """, 526 | """ 527 | defmodule F do 528 | @moduledoc false 529 | @derive Inspect 530 | @derive {Foo, bar: :baz} 531 | defstruct [:a] 532 | # comment for foo 533 | def foo, do: :ok 534 | end 535 | """ 536 | ) 537 | 538 | assert_style "@derive Inspect" 539 | end 540 | 541 | test "de-aliases use/behaviour/import/moduledoc" do 542 | assert_style( 543 | """ 544 | defmodule MyModule do 545 | alias A.B.C 546 | @moduledoc "Implements \#{C.foo()}!" 547 | alias D.F.C 548 | import C 549 | alias G.H.C 550 | @behaviour C 551 | alias Z.X.C 552 | use SomeMacro, with: C 553 | alias A.B, as: D 554 | import D 555 | end 556 | """, 557 | """ 558 | defmodule MyModule do 559 | @moduledoc "Implements \#{A.B.C.foo()}!" 560 | @behaviour G.H.C 561 | 562 | use SomeMacro, with: Z.X.C 563 | 564 | import A.B 565 | import D.F.C 566 | 567 | alias A.B, as: D 568 | alias A.B.C 569 | alias D.F.C 570 | alias G.H.C 571 | alias Z.X.C 572 | end 573 | """ 574 | ) 575 | end 576 | 577 | describe "module attribute lifting" do 578 | test "replaces uses in other attributes and `use` correctly" do 579 | assert_style( 580 | """ 581 | defmodule MyGreatLibrary do 582 | @library_options [...] 583 | @moduledoc make_pretty_docs(@library_options) 584 | use OptionsMagic, my_opts: @library_options 585 | end 586 | """, 587 | """ 588 | library_options = [...] 589 | 590 | defmodule MyGreatLibrary do 591 | @moduledoc make_pretty_docs(library_options) 592 | use OptionsMagic, my_opts: unquote(library_options) 593 | 594 | @library_options library_options 595 | end 596 | """ 597 | ) 598 | end 599 | 600 | test "works with `quote`" do 601 | assert_style( 602 | """ 603 | quote do 604 | @library_options [...] 605 | @moduledoc make_pretty_docs(@library_options) 606 | use OptionsMagic, my_opts: @library_options 607 | end 608 | """, 609 | """ 610 | library_options = [...] 611 | 612 | quote do 613 | @moduledoc make_pretty_docs(library_options) 614 | use OptionsMagic, my_opts: unquote(library_options) 615 | 616 | @library_options library_options 617 | end 618 | """ 619 | ) 620 | end 621 | end 622 | end 623 | -------------------------------------------------------------------------------- /test/style/single_node_test.exs: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Styler.Style.SingleNodeTest do 12 | use Styler.StyleCase, async: true 13 | 14 | test "string sigil rewrites" do 15 | assert_style ~s|""| 16 | assert_style ~s|"\\""| 17 | assert_style ~s|"\\"\\""| 18 | assert_style ~s|"\\"\\"\\""| 19 | assert_style ~s|"\\"\\"\\"\\""|, ~s|~s("""")| 20 | # choose closing delimiter wisely, based on what has the least conflicts, in the styliest order 21 | assert_style ~s/"\\"\\"\\"\\" )"/, ~s/~s{"""" )}/ 22 | assert_style ~s/"\\"\\"\\"\\" })"/, ~s/~s|"""" })|/ 23 | assert_style ~s/"\\"\\"\\"\\" |})"/, ~s/~s["""" |})]/ 24 | assert_style ~s/"\\"\\"\\"\\" ]|})"/, ~s/~s'"""" ]|})'/ 25 | assert_style ~s/"\\"\\"\\"\\" ']|})"/, ~s/~s<"""" ']|})>/ 26 | assert_style ~s/"\\"\\"\\"\\" >']|})"/, ~s|~s/"""" >']\|})/| 27 | assert_style ~s/"\\"\\"\\"\\" \/>']|})"/, ~s|~s("""" />']\|}\\))| 28 | end 29 | 30 | describe "{Keyword/Map}.merge/2 of a single key => *.put/3" do 31 | test "in a pipe" do 32 | for module <- ~w(Map Keyword) do 33 | assert_style("foo |> #{module}.merge(%{one_key: :bar}) |> bop()", "foo |> #{module}.put(:one_key, :bar) |> bop()") 34 | end 35 | end 36 | 37 | test "normal call" do 38 | for module <- ~w(Map Keyword) do 39 | assert_style("#{module}.merge(foo, %{one_key: :bar})", "#{module}.put(foo, :one_key, :bar)") 40 | assert_style("#{module}.merge(foo, one_key: :bar)", "#{module}.put(foo, :one_key, :bar)") 41 | # # doesn't rewrite if there's a custom merge strategy 42 | assert_style("#{module}.merge(foo, %{one_key: :bar}, custom_merge_strategy)") 43 | # # doesn't rewrite if > 1 key 44 | assert_style("#{module}.merge(foo, %{a: :b, c: :d})") 45 | end 46 | end 47 | end 48 | 49 | test "{Map/Keyword}.drop with a single key" do 50 | for module <- ~w(Map Keyword) do 51 | for singular <- ~w(:key key %{} [] 1 "key") do 52 | assert_style("#{module}.drop(foo, [#{singular}])", "#{module}.delete(foo, #{singular})") 53 | assert_style("foo |> #{module}.drop([#{singular}]) |> bar()", "foo |> #{module}.delete(#{singular}) |> bar()") 54 | end 55 | 56 | assert "#{module}.drop(foo, [])" 57 | assert "foo |> #{module}.drop([]) |> bar()" 58 | 59 | for plurality <- ["[]", "[a, b]", "[a | b]", "some_list"] do 60 | assert_style("#{module}.drop(foo, #{plurality})") 61 | assert_style("foo |> #{module}.drop(#{plurality}) |> bar()") 62 | end 63 | end 64 | end 65 | 66 | describe "Timex.now/0,1" do 67 | test "Timex.now/0 => DateTime.utc_now/0" do 68 | assert_style("Timex.now()", "DateTime.utc_now()") 69 | assert_style("Timex.now() |> foo() |> bar()", "DateTime.utc_now() |> foo() |> bar()") 70 | end 71 | 72 | test "leaves Timex.now/1 alone" do 73 | assert_style("Timex.now(tz)", "Timex.now(tz)") 74 | 75 | assert_style( 76 | """ 77 | timezone 78 | |> Timex.now() 79 | |> foo() 80 | """, 81 | """ 82 | timezone 83 | |> Timex.now() 84 | |> foo() 85 | """ 86 | ) 87 | end 88 | end 89 | 90 | test "{DateTime,NaiveDateTime,Time,Date}.compare to {DateTime,NaiveDateTime,Time,Date}.before?" do 91 | assert_style("DateTime.compare(foo, bar) == :lt", "DateTime.before?(foo, bar)") 92 | assert_style("NaiveDateTime.compare(foo, bar) == :lt", "NaiveDateTime.before?(foo, bar)") 93 | assert_style("Time.compare(foo, bar) == :lt", "Time.before?(foo, bar)") 94 | assert_style("Date.compare(foo, bar) == :lt", "Date.before?(foo, bar)") 95 | end 96 | 97 | test "{DateTime,NaiveDateTime,Time,Date}.compare to {DateTime,NaiveDateTime,Time,Date}.after?" do 98 | assert_style("DateTime.compare(foo, bar) == :gt", "DateTime.after?(foo, bar)") 99 | assert_style("NaiveDateTime.compare(foo, bar) == :gt", "NaiveDateTime.after?(foo, bar)") 100 | assert_style("Time.compare(foo, bar) == :gt", "Time.after?(foo, bar)") 101 | assert_style("Time.compare(foo, bar) == :gt", "Time.after?(foo, bar)") 102 | end 103 | 104 | describe "def / defp" do 105 | test "0-arity functions have parens removed" do 106 | assert_style("def foo(), do: :ok", "def foo, do: :ok") 107 | assert_style("defp foo(), do: :ok", "defp foo, do: :ok") 108 | 109 | assert_style( 110 | """ 111 | def foo() do 112 | :ok 113 | end 114 | """, 115 | """ 116 | def foo do 117 | :ok 118 | end 119 | """ 120 | ) 121 | 122 | assert_style( 123 | """ 124 | defp foo() do 125 | :ok 126 | end 127 | """, 128 | """ 129 | defp foo do 130 | :ok 131 | end 132 | """ 133 | ) 134 | 135 | # Regression: be wary of invocations with extra parens from metaprogramming 136 | assert_style("def metaprogramming(foo)(), do: bar") 137 | end 138 | 139 | test "prefers implicit try" do 140 | for def_style <- ~w(def defp) do 141 | assert_style( 142 | """ 143 | #{def_style} foo() do 144 | try do 145 | :ok 146 | rescue 147 | exception -> :excepted 148 | catch 149 | :a_throw -> :thrown 150 | else 151 | i_forgot -> i_forgot.this_could_happen 152 | after 153 | :done 154 | end 155 | end 156 | """, 157 | """ 158 | #{def_style} foo do 159 | :ok 160 | rescue 161 | exception -> :excepted 162 | catch 163 | :a_throw -> :thrown 164 | else 165 | i_forgot -> i_forgot.this_could_happen 166 | after 167 | :done 168 | end 169 | """ 170 | ) 171 | end 172 | end 173 | 174 | test "doesnt rewrite when there are other things in the body" do 175 | assert_style(""" 176 | def foo do 177 | try do 178 | :ok 179 | rescue 180 | exception -> :excepted 181 | end 182 | 183 | :after_try 184 | end 185 | """) 186 | end 187 | end 188 | 189 | describe "RHS pattern matching" do 190 | test "left arrows" do 191 | assert_style("with {:ok, result = %{}} <- foo, do: result", "with {:ok, %{} = result} <- foo, do: result") 192 | assert_style("for map = %{} <- maps, do: map[:key]", "for %{} = map <- maps, do: map[:key]") 193 | end 194 | 195 | test "case statements" do 196 | assert_style( 197 | """ 198 | case foo do 199 | bar = %{baz: baz? = true} -> :baz? 200 | opts = [[a = %{}] | _] -> a 201 | end 202 | """, 203 | """ 204 | case foo do 205 | %{baz: true = baz?} = bar -> :baz? 206 | [[%{} = a] | _] = opts -> a 207 | end 208 | """ 209 | ) 210 | end 211 | 212 | test "regression: ignores unquoted cases" do 213 | assert_style("case foo, do: unquote(quoted)") 214 | end 215 | 216 | test "removes a double-var assignment when one var is _" do 217 | assert_style("def foo(_ = bar), do: bar", "def foo(bar), do: bar") 218 | assert_style("def foo(bar = _), do: bar", "def foo(bar), do: bar") 219 | 220 | assert_style( 221 | """ 222 | case foo do 223 | bar = _ -> :ok 224 | end 225 | """, 226 | """ 227 | case foo do 228 | bar -> :ok 229 | end 230 | """ 231 | ) 232 | 233 | assert_style( 234 | """ 235 | case foo do 236 | _ = bar -> :ok 237 | end 238 | """, 239 | """ 240 | case foo do 241 | bar -> :ok 242 | end 243 | """ 244 | ) 245 | end 246 | 247 | test "defs" do 248 | assert_style( 249 | "def foo(bar = %{baz: baz? = true}, opts = [[a = %{}] | _]), do: :ok", 250 | "def foo(%{baz: true = baz?} = bar, [[%{} = a] | _] = opts), do: :ok" 251 | ) 252 | end 253 | 254 | test "anonymous functions" do 255 | assert_style( 256 | "fn bar = %{baz: baz? = true}, opts = [[a = %{}] | _] -> :ok end", 257 | "fn %{baz: true = baz?} = bar, [[%{} = a] | _] = opts -> :ok end" 258 | ) 259 | end 260 | 261 | test "leaves those poor case statements alone!" do 262 | assert_style(""" 263 | cond do 264 | foo = Repo.get(Bar, 1) -> foo 265 | x == y -> :kaboom? 266 | true -> :else 267 | end 268 | """) 269 | end 270 | 271 | test "with statements" do 272 | assert_style( 273 | """ 274 | with ok = :ok <- foo, :ok <- yeehaw() do 275 | ok 276 | else 277 | error = :error -> error 278 | other -> other 279 | end 280 | """, 281 | """ 282 | with :ok = ok <- foo, :ok <- yeehaw() do 283 | ok 284 | else 285 | :error = error -> error 286 | other -> other 287 | end 288 | """ 289 | ) 290 | end 291 | end 292 | 293 | describe "numbers" do 294 | test "styles floats and integers with >4 digits" do 295 | assert_style("10000", "10_000") 296 | assert_style("1_0_0_0_0", "10_000") 297 | assert_style("-543213", "-543_213") 298 | assert_style("123456789", "123_456_789") 299 | assert_style("55333.22", "55_333.22") 300 | assert_style("-123456728.0001", "-123_456_728.0001") 301 | end 302 | 303 | test "stays away from small numbers, strings and science" do 304 | assert_style("1234") 305 | assert_style("9999") 306 | assert_style(~s|"10000"|) 307 | assert_style("0xFFFF") 308 | assert_style("0x123456") 309 | assert_style("0b1111_1111_1111_1111") 310 | assert_style("0o777_7777") 311 | end 312 | end 313 | 314 | describe "Enum.into and $collectable.new" do 315 | test "into an empty map" do 316 | assert_style("Enum.into(a, %{})", "Map.new(a)") 317 | assert_style("Enum.into(a, %{}, mapper)", "Map.new(a, mapper)") 318 | end 319 | 320 | test "into a list" do 321 | assert_style("Enum.into(a, [])", "Enum.to_list(a)") 322 | assert_style("Enum.into(a, [], mapper)", "Enum.map(a, mapper)") 323 | assert_style("a |> Enum.into([]) |> bar()", "a |> Enum.to_list() |> bar()") 324 | assert_style("a |> Enum.into([], mapper) |> bar()", "a |> Enum.map(mapper) |> bar()") 325 | end 326 | 327 | test "into a collectable" do 328 | assert_style("Enum.into(a, foo)") 329 | assert_style("Enum.into(a, foo, mapper)") 330 | 331 | for collectable <- ~W(Map Keyword MapSet), new = "#{collectable}.new" do 332 | assert_style("Enum.into(a, #{new})", "#{new}(a)") 333 | assert_style("Enum.into(a, #{new}, mapper)", "#{new}(a, mapper)") 334 | end 335 | end 336 | end 337 | 338 | describe "Enum.reverse/1 and ++" do 339 | test "optimizes into `Enum.reverse/2`" do 340 | assert_style("Enum.reverse(foo) ++ bar", "Enum.reverse(foo, bar)") 341 | assert_style("Enum.reverse(foo, bar) ++ bar") 342 | end 343 | end 344 | 345 | describe "to_timeout" do 346 | test "to next unit" do 347 | facts = [ 348 | {1000, :millisecond, :second}, 349 | {60, :second, :minute}, 350 | {60, :minute, :hour}, 351 | {24, :hour, :day}, 352 | {7, :day, :week} 353 | ] 354 | 355 | for {n, unit, next} <- facts do 356 | assert_style "to_timeout(#{unit}: #{n} * m)", "to_timeout(#{next}: m)" 357 | assert_style "to_timeout(#{unit}: m * #{n})", "to_timeout(#{next}: m)" 358 | assert_style "to_timeout(#{unit}: #{n})", "to_timeout(#{next}: 1)" 359 | end 360 | 361 | assert_style "to_timeout(second: 60 * 60)", "to_timeout(hour: 1)" 362 | end 363 | 364 | test "doesnt mess with" do 365 | assert_style "to_timeout(hour: n * m)" 366 | assert_style "to_timeout(whatever)" 367 | assert_style "to_timeout(hour: 24 * 1, second: 60 * 4)" 368 | end 369 | end 370 | end 371 | -------------------------------------------------------------------------------- /test/style/styles_test.exs: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Styler.Style.StylesTest do 12 | @moduledoc """ 13 | A place for tests that make sure our styles play nicely with each other 14 | """ 15 | use Styler.StyleCase, async: true 16 | 17 | describe "pipes + defs" do 18 | test "pipes doesnt abuse meta and break defs" do 19 | assert_style( 20 | """ 21 | foo 22 | |> bar(fn baz -> 23 | def widget() do 24 | :bop 25 | end 26 | end) 27 | """, 28 | """ 29 | bar(foo, fn baz -> 30 | def widget do 31 | :bop 32 | end 33 | end) 34 | """ 35 | ) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/style_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Styler.StyleTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Styler.Style, only: [displace_comments: 2, shift_comments: 3] 5 | 6 | @code """ 7 | # Above module 8 | defmodule Foo do 9 | # Top of module 10 | 11 | @moduledoc "This is a moduledoc" 12 | 13 | 14 | # Above spec 15 | @spec some_fun( 16 | atom() 17 | ) 18 | :: atom() 19 | 20 | @doc "This is a function" 21 | # Above def 22 | def some_fun( 23 | # Before arg 24 | arg 25 | # After arg 26 | ) do 27 | # In function body 28 | :ok 29 | end 30 | 31 | # After def 32 | end 33 | # After module 34 | """ 35 | 36 | @comments @code |> Styler.string_to_ast() |> elem(1) 37 | 38 | describe "displace_comments/2" do 39 | test "Doesn't lose any comments" do 40 | new_comments = displace_comments(@comments, 0..0) 41 | 42 | for comment <- @comments do 43 | assert Enum.any?(new_comments, &(&1.text == comment.text)) 44 | end 45 | end 46 | 47 | test "Moves comments within the range to the start of the range" do 48 | # Sanity-check line numbers in test fixture 49 | before = [ 50 | {"# Above def", 15}, 51 | {"# Before arg", 17}, 52 | {"# After arg", 19} 53 | ] 54 | 55 | for {text, line} <- before do 56 | assert line == Enum.find(@comments, &(&1.text == text)).line 57 | end 58 | 59 | # Simulate collapsing the `def` on lines 16-20 60 | new_comments = displace_comments(@comments, 16..20) 61 | 62 | expected = [ 63 | {"# Above def", 15}, 64 | {"# Before arg", 16}, 65 | {"# After arg", 16} 66 | ] 67 | 68 | for {text, line} <- expected do 69 | assert line == Enum.find(new_comments, &(&1.text == text)).line 70 | end 71 | end 72 | end 73 | 74 | describe "shift_comments/3" do 75 | test "Doesn't lose any comments" do 76 | new_comments = shift_comments(@comments, 1..30, 1) 77 | 78 | for comment <- @comments do 79 | assert Enum.any?(new_comments, &(&1.text == comment.text)) 80 | end 81 | end 82 | 83 | test "Moves comments after the specified line by the specified delta" do 84 | # Sanity-check line numbers in test fixture 85 | before = [ 86 | {"# Above module", 1}, 87 | {"# Top of module", 3}, 88 | {"# Above def", 15}, 89 | {"# In function body", 21}, 90 | {"# After def", 25}, 91 | {"# After module", 27} 92 | ] 93 | 94 | for {text, line} <- before do 95 | assert line == Enum.find(@comments, &(&1.text == text)).line 96 | end 97 | 98 | # Simulate collapsing the `def` on lines 16-20, shifting everything afterword up by 4 99 | new_comments = shift_comments(@comments, 21..23, -4) 100 | 101 | expected = [ 102 | # Before 21 doesn't get moved 103 | {"# Above module", 1}, 104 | {"# Top of module", 3}, 105 | {"# Above def", 15}, 106 | # 21 does get moved 107 | {"# In function body", 17}, 108 | # After 23 doesn't get moved 109 | {"# After def", 25}, 110 | {"# After module", 27} 111 | ] 112 | 113 | for {text, line} <- expected do 114 | assert line == Enum.find(new_comments, &(&1.text == text)).line 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /test/support/style_case.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Styler.StyleCase do 12 | @moduledoc """ 13 | Helpers around testing Style rules. 14 | """ 15 | use ExUnit.CaseTemplate 16 | 17 | using options do 18 | quote do 19 | import unquote(__MODULE__), 20 | only: [assert_style: 1, assert_style: 2, style: 1, style: 2, format_diff: 2, format_diff: 3] 21 | 22 | @filename unquote(options)[:filename] || "testfile" 23 | @ordered_siblings unquote(options)[:ordered_siblings] || false 24 | end 25 | end 26 | 27 | setup_all do 28 | Styler.Config.set([]) 29 | end 30 | 31 | defmacro assert_style(before, expected \\ nil) do 32 | expected = expected || before 33 | 34 | quote bind_quoted: [before: before, expected: expected], location: :keep do 35 | alias Styler.Zipper 36 | 37 | expected = String.trim(expected) 38 | {styled_ast, styled, styled_comments} = style(before, @filename) 39 | 40 | if styled != expected and ExUnit.configuration()[:trace] do 41 | IO.puts("\n======Given=============\n") 42 | IO.puts(before) 43 | {before_ast, before_comments} = Styler.string_to_ast(before) 44 | dbg(before_ast) 45 | dbg(before_comments) 46 | IO.puts("======Expected AST==========\n") 47 | {expected_ast, expected_comments} = Styler.string_to_ast(expected) 48 | dbg(expected_ast) 49 | dbg(expected_comments) 50 | IO.puts("======Got AST===============\n") 51 | dbg(styled_ast) 52 | dbg(styled_comments) 53 | IO.puts("========================\n") 54 | end 55 | 56 | if expected == styled do 57 | assert true 58 | else 59 | flunk(format_diff(expected, styled)) 60 | end 61 | 62 | # Make sure we're keeping lines in check 63 | styled_ast 64 | |> Zipper.zip() 65 | |> Zipper.traverse(fn 66 | {{node, meta, _} = ast, _} = zipper -> 67 | line = meta[:line] 68 | 69 | up = Zipper.up(zipper) 70 | # body blocks - for example, the block node for an anonymous function - don't have line meta 71 | # yes, i just did `&& case`. sometimes it's funny to write ugly things in my project that's all about style. 72 | # i believe they calls that one "irony" 73 | body_block? = 74 | node == :__block__ && 75 | case up && Zipper.node(up) do 76 | # top of a snippet 77 | nil -> true 78 | # do/else/etc 79 | {{:__block__, _, [_]}, {:__block__, [], _}} -> true 80 | # anonymous function 81 | {:->, _, _} -> true 82 | _ -> false 83 | end 84 | 85 | # This isn't enabled in any test, but can be a useful audit 86 | if @ordered_siblings do 87 | case Zipper.left(zipper) do 88 | {{_, prev_meta, _} = prev, _} -> 89 | if prev_meta[:line] && meta[:line] && prev_meta[:line] > meta[:line] do 90 | if ExUnit.configuration()[:trace] do 91 | dbg(prev) 92 | dbg(ast) 93 | end 94 | 95 | assert(prev_meta[:line] <= meta[:line], "Previous node had a higher line than this node") 96 | end 97 | 98 | _ -> 99 | :ok 100 | end 101 | end 102 | 103 | if is_nil(line) and not body_block? do 104 | IO.puts("missing `:line` meta in node:") 105 | dbg(ast) 106 | 107 | IO.puts("tree:") 108 | dbg(styled_ast) 109 | 110 | IO.puts("expected:") 111 | dbg(elem(Styler.string_to_ast(expected), 0)) 112 | 113 | IO.puts("code:\n#{styled}") 114 | flunk("") 115 | end 116 | 117 | zipper 118 | 119 | zipper -> 120 | zipper 121 | end) 122 | 123 | # Idempotency 124 | {_, restyled, _} = style(styled, @filename) 125 | 126 | if restyled == styled do 127 | assert true 128 | else 129 | flunk( 130 | format_diff(styled, restyled, "expected styling to be idempotent, but a second pass resulted in more changes.") 131 | ) 132 | end 133 | end 134 | end 135 | 136 | def style(code, filename \\ "testfile") do 137 | {ast, comments} = Styler.string_to_ast(code) 138 | {styled_ast, comments} = Styler.style({ast, comments}, filename, on_error: :raise) 139 | 140 | try do 141 | styled_code = styled_ast |> Styler.ast_to_string(comments) |> String.trim_trailing("\n") 142 | {styled_ast, styled_code, comments} 143 | rescue 144 | exception -> 145 | IO.inspect(styled_ast, label: [IO.ANSI.red(), "**Style created invalid ast:**", IO.ANSI.light_red()]) 146 | reraise exception, __STACKTRACE__ 147 | end 148 | end 149 | 150 | def format_diff(expected, styled, prelude \\ "Styling produced unexpected results") do 151 | # reaching into private ExUnit stuff, uh oh! 152 | # this gets us the nice diffing from ExUnit while allowing us to print our code blocks as strings rather than inspected strings 153 | {%{left: expected, right: styled}, _} = ExUnit.Diff.compute(expected, styled, :==) 154 | 155 | expected = 156 | for {diff?, content} <- expected.contents do 157 | cond do 158 | diff? and String.trim_leading(Macro.unescape_string(content)) == "" -> [:red_background, content, :reset] 159 | diff? -> [:red, content, :reset] 160 | true -> content 161 | end 162 | end 163 | 164 | styled = 165 | for {diff?, content} <- styled.contents do 166 | cond do 167 | diff? and String.trim_leading(Macro.unescape_string(content)) == "" -> [:green_background, content, :reset] 168 | diff? -> [:green, content, :reset] 169 | true -> content 170 | end 171 | end 172 | 173 | header = IO.ANSI.format([:red, prelude, :reset]) 174 | 175 | expected = 176 | [[:cyan, "expected:\n", :reset] | expected] 177 | |> IO.ANSI.format() 178 | |> to_string() 179 | |> Macro.unescape_string() 180 | |> String.replace("\n", "\n ") 181 | 182 | styled = 183 | [[:cyan, "styled:\n", :reset] | styled] 184 | |> IO.ANSI.format() 185 | |> to_string() 186 | |> Macro.unescape_string() 187 | |> String.replace("\n", "\n ") 188 | 189 | """ 190 | #{header} 191 | #{expected} 192 | #{styled} 193 | """ 194 | end 195 | end 196 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | ExUnit.start(capture_log: true, formatters: [JUnitFormatter, ExUnit.CLIFormatter]) 12 | --------------------------------------------------------------------------------