├── .credo.exs ├── .dialyzer_ignore.exs ├── .formatter.exs ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .iex.exs ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE.md ├── README.md ├── bench ├── benchee_helper.exs └── rewrite_new_bench.exs ├── coveralls.json ├── lib ├── hook │ └── dot_formatter_updater.ex ├── rewrite.ex └── rewrite │ ├── application.ex │ ├── dot_formatter.ex │ ├── dot_formatter_error.ex │ ├── error.ex │ ├── filetype.ex │ ├── hook.ex │ ├── source.ex │ ├── source │ └── ex.ex │ ├── source_error.ex │ ├── source_key_error.ex │ └── update_error.ex ├── mix.exs ├── mix.lock ├── test ├── .formatter.exs ├── fixtures │ ├── error.ex │ └── source │ │ ├── double.ex │ │ ├── hello.txt │ │ ├── module_ast_contained.ex │ │ ├── nested.ex │ │ └── simple.ex ├── hook │ └── dot_formatter_updater_test.exs ├── rewrite │ ├── dot_formatter_test.exs │ ├── source │ │ └── ex_test.exs │ └── source_test.exs ├── rewrite_test.exs ├── support │ ├── alt_ex_plugin.ex │ ├── alt_ex_wrapper_plugin.ex │ ├── extension_w_plugin.ex │ ├── inspect_hook.ex │ ├── new_line_to_dot_plugin.ex │ ├── plts │ │ └── .keep │ ├── rewrite_case.ex │ └── sigil_w_plugin.ex └── test_helper.exs └── tmp └── .keep /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any config using `mix credo -C `. If no config name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: [ 25 | "lib/", 26 | "src/", 27 | "test/", 28 | "web/", 29 | "apps/*/lib/", 30 | "apps/*/src/", 31 | "apps/*/test/", 32 | "apps/*/web/" 33 | ], 34 | excluded: [ 35 | ~r"/_build/", 36 | ~r"/deps/", 37 | ~r"/node_modules/", 38 | ~r"/test/fixtures/" 39 | ] 40 | }, 41 | # 42 | # Load and configure plugins here: 43 | # 44 | plugins: [], 45 | # 46 | # If you create your own checks, you must specify the source files for 47 | # them here, so they can be loaded by Credo before running the analysis. 48 | # 49 | requires: [], 50 | # 51 | # If you want to enforce a style guide and need a more traditional linting 52 | # experience, you can change `strict` to `true` below: 53 | # 54 | strict: true, 55 | # 56 | # To modify the timeout for parsing files, change this value: 57 | # 58 | parse_timeout: 5000, 59 | # 60 | # If you want to use uncolored output by default, you can change `color` 61 | # to `false` below: 62 | # 63 | color: true, 64 | # 65 | # You can customize the parameters of any check by adding a second element 66 | # to the tuple. 67 | # 68 | # To disable a check put `false` as second element: 69 | # 70 | # {Credo.Check.Design.DuplicatedCode, false} 71 | # 72 | checks: %{ 73 | enabled: [ 74 | # 75 | ## Consistency Checks 76 | # 77 | {Credo.Check.Consistency.ExceptionNames, []}, 78 | {Credo.Check.Consistency.LineEndings, []}, 79 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 80 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 81 | {Credo.Check.Consistency.SpaceInParentheses, []}, 82 | {Credo.Check.Consistency.TabsOrSpaces, []}, 83 | 84 | # 85 | ## Design Checks 86 | # 87 | # You can customize the priority of any check 88 | # Priority values are: `low, normal, high, higher` 89 | # 90 | {Credo.Check.Design.AliasUsage, 91 | [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, 92 | # You can also customize the exit_status of each check. 93 | # If you don't want TODO comments to cause `mix credo` to fail, just 94 | # set this value to 0 (zero). 95 | # 96 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 97 | {Credo.Check.Design.TagFIXME, []}, 98 | 99 | # 100 | ## Readability Checks 101 | # 102 | {Credo.Check.Readability.AliasOrder, []}, 103 | {Credo.Check.Readability.FunctionNames, []}, 104 | {Credo.Check.Readability.LargeNumbers, []}, 105 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 106 | {Credo.Check.Readability.ModuleAttributeNames, []}, 107 | {Credo.Check.Readability.ModuleDoc, []}, 108 | {Credo.Check.Readability.ModuleNames, []}, 109 | {Credo.Check.Readability.ParenthesesInCondition, []}, 110 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 111 | {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, 112 | {Credo.Check.Readability.PredicateFunctionNames, []}, 113 | {Credo.Check.Readability.PreferImplicitTry, []}, 114 | {Credo.Check.Readability.RedundantBlankLines, []}, 115 | {Credo.Check.Readability.Semicolons, []}, 116 | {Credo.Check.Readability.SpaceAfterCommas, []}, 117 | {Credo.Check.Readability.StringSigils, []}, 118 | {Credo.Check.Readability.TrailingBlankLine, []}, 119 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 120 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 121 | {Credo.Check.Readability.VariableNames, []}, 122 | {Credo.Check.Readability.WithSingleClause, []}, 123 | 124 | # 125 | ## Refactoring Opportunities 126 | # 127 | {Credo.Check.Refactor.Apply, []}, 128 | {Credo.Check.Refactor.CondStatements, []}, 129 | {Credo.Check.Refactor.CyclomaticComplexity, [max_complexity: 10]}, 130 | {Credo.Check.Refactor.FunctionArity, []}, 131 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 132 | {Credo.Check.Refactor.MatchInCondition, []}, 133 | {Credo.Check.Refactor.MapJoin, []}, 134 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 135 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 136 | {Credo.Check.Refactor.Nesting, [max_nesting: 3]}, 137 | {Credo.Check.Refactor.UnlessWithElse, []}, 138 | {Credo.Check.Refactor.WithClauses, []}, 139 | {Credo.Check.Refactor.FilterFilter, []}, 140 | {Credo.Check.Refactor.RejectReject, []}, 141 | {Credo.Check.Refactor.RedundantWithClauseResult, []}, 142 | 143 | # 144 | ## Warnings 145 | # 146 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, 147 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 148 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 149 | {Credo.Check.Warning.IExPry, []}, 150 | {Credo.Check.Warning.IoInspect, []}, 151 | {Credo.Check.Warning.OperationOnSameValues, []}, 152 | {Credo.Check.Warning.OperationWithConstantResult, []}, 153 | {Credo.Check.Warning.RaiseInsideRescue, []}, 154 | {Credo.Check.Warning.SpecWithStruct, []}, 155 | {Credo.Check.Warning.WrongTestFileExtension, []}, 156 | {Credo.Check.Warning.UnusedEnumOperation, []}, 157 | {Credo.Check.Warning.UnusedFileOperation, []}, 158 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 159 | {Credo.Check.Warning.UnusedListOperation, []}, 160 | {Credo.Check.Warning.UnusedPathOperation, []}, 161 | {Credo.Check.Warning.UnusedRegexOperation, []}, 162 | {Credo.Check.Warning.UnusedStringOperation, []}, 163 | {Credo.Check.Warning.UnusedTupleOperation, []}, 164 | {Credo.Check.Warning.UnsafeExec, []} 165 | ], 166 | disabled: [ 167 | # 168 | # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) 169 | 170 | # 171 | # Controversial and experimental checks (opt-in, just move the check to `:enabled` 172 | # and be sure to use `mix credo --strict` to see low priority checks) 173 | # 174 | {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, 175 | {Credo.Check.Consistency.UnusedVariableNames, []}, 176 | {Credo.Check.Design.DuplicatedCode, []}, 177 | {Credo.Check.Design.SkipTestWithoutComment, []}, 178 | {Credo.Check.Readability.AliasAs, []}, 179 | {Credo.Check.Readability.BlockPipe, []}, 180 | {Credo.Check.Readability.ImplTrue, []}, 181 | {Credo.Check.Readability.MultiAlias, []}, 182 | {Credo.Check.Readability.NestedFunctionCalls, []}, 183 | {Credo.Check.Readability.SeparateAliasRequire, []}, 184 | {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, 185 | {Credo.Check.Readability.SinglePipe, []}, 186 | {Credo.Check.Readability.Specs, []}, 187 | {Credo.Check.Readability.StrictModuleLayout, []}, 188 | {Credo.Check.Readability.WithCustomTaggedTuple, []}, 189 | {Credo.Check.Refactor.ABCSize, []}, 190 | {Credo.Check.Refactor.AppendSingleItem, []}, 191 | {Credo.Check.Refactor.DoubleBooleanNegation, []}, 192 | {Credo.Check.Refactor.FilterReject, []}, 193 | {Credo.Check.Refactor.IoPuts, []}, 194 | {Credo.Check.Refactor.MapMap, []}, 195 | {Credo.Check.Refactor.ModuleDependencies, []}, 196 | {Credo.Check.Refactor.NegatedIsNil, []}, 197 | {Credo.Check.Refactor.PipeChainStart, []}, 198 | {Credo.Check.Refactor.RejectFilter, []}, 199 | {Credo.Check.Refactor.VariableRebinding, []}, 200 | {Credo.Check.Warning.LazyLogging, []}, 201 | {Credo.Check.Warning.LeakyEnvironment, []}, 202 | {Credo.Check.Warning.MapGetUnsafePass, []}, 203 | {Credo.Check.Warning.MixEnv, []}, 204 | {Credo.Check.Warning.UnsafeToAtom, []} 205 | 206 | # {Credo.Check.Refactor.MapInto, []}, 207 | 208 | # 209 | # Custom checks can be created using `mix credo.gen.check`. 210 | # 211 | ] 212 | } 213 | } 214 | ] 215 | } 216 | -------------------------------------------------------------------------------- /.dialyzer_ignore.exs: -------------------------------------------------------------------------------- 1 | [ 2 | ~r|lib/rewrite/dot_formatter.ex:.*:pattern_match| 3 | ] 4 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["{mix,.formatter}.exs", "{config,lib}/**/*.{ex,exs}"], 3 | subdirectories: ["test"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # Created with GitHubActions version 0.2.27 2 | name: CI 3 | env: 4 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 5 | on: 6 | - pull_request 7 | - push 8 | jobs: 9 | linux: 10 | name: Test on Ubuntu (Elixir ${{ matrix.elixir }}, OTP ${{ matrix.otp }}) 11 | runs-on: ubuntu-20.04 12 | strategy: 13 | matrix: 14 | elixir: 15 | - '1.13.4' 16 | - '1.14.5' 17 | - '1.15.8' 18 | - '1.16.3' 19 | - '1.17.3' 20 | - '1.18.0' 21 | otp: 22 | - '22.3' 23 | - '23.3' 24 | - '24.3' 25 | - '25.3' 26 | - '26.2' 27 | - '27.2' 28 | exclude: 29 | - elixir: '1.13.4' 30 | otp: '26.2' 31 | - elixir: '1.13.4' 32 | otp: '27.2' 33 | - elixir: '1.14.5' 34 | otp: '22.3' 35 | - elixir: '1.14.5' 36 | otp: '27.2' 37 | - elixir: '1.15.8' 38 | otp: '22.3' 39 | - elixir: '1.15.8' 40 | otp: '23.3' 41 | - elixir: '1.15.8' 42 | otp: '27.2' 43 | - elixir: '1.16.3' 44 | otp: '22.3' 45 | - elixir: '1.16.3' 46 | otp: '23.3' 47 | - elixir: '1.16.3' 48 | otp: '27.2' 49 | - elixir: '1.17.3' 50 | otp: '22.3' 51 | - elixir: '1.17.3' 52 | otp: '23.3' 53 | - elixir: '1.17.3' 54 | otp: '24.3' 55 | - elixir: '1.18.0' 56 | otp: '22.3' 57 | - elixir: '1.18.0' 58 | otp: '23.3' 59 | - elixir: '1.18.0' 60 | otp: '24.3' 61 | steps: 62 | - name: Checkout 63 | uses: actions/checkout@v4 64 | - name: Setup Elixir 65 | uses: erlef/setup-beam@v1 66 | with: 67 | elixir-version: ${{ matrix.elixir }} 68 | otp-version: ${{ matrix.otp }} 69 | - name: Restore deps 70 | uses: actions/cache@v4 71 | with: 72 | path: deps 73 | key: deps-${{ runner.os }}-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 74 | - name: Restore _build 75 | uses: actions/cache@v4 76 | with: 77 | path: _build 78 | key: _build-${{ runner.os }}-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 79 | - name: Restore test/support/plts 80 | uses: actions/cache@v4 81 | with: 82 | path: test/support/plts 83 | key: test/support/plts-${{ runner.os }}-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 84 | if: ${{ contains(matrix.elixir, '1.18.0') && contains(matrix.otp, '27.2') }} 85 | - name: Get dependencies 86 | run: mix deps.get 87 | - name: Compile dependencies 88 | run: MIX_ENV=test mix deps.compile 89 | - name: Compile project 90 | run: MIX_ENV=test mix compile --warnings-as-errors 91 | - name: Check unused dependencies 92 | if: ${{ contains(matrix.elixir, '1.18.0') && contains(matrix.otp, '27.2') }} 93 | run: mix deps.unlock --check-unused 94 | - name: Check code format 95 | if: ${{ contains(matrix.elixir, '1.18.0') && contains(matrix.otp, '27.2') }} 96 | run: mix format --check-formatted 97 | - name: Lint code 98 | if: ${{ contains(matrix.elixir, '1.18.0') && contains(matrix.otp, '27.2') }} 99 | run: mix credo --strict 100 | - name: Run tests 101 | run: mix test 102 | if: ${{ !(contains(matrix.elixir, '1.18.0') && contains(matrix.otp, '27.2')) }} 103 | - name: Run tests with coverage 104 | run: mix coveralls.github 105 | if: ${{ contains(matrix.elixir, '1.18.0') && contains(matrix.otp, '27.2') }} 106 | - name: Static code analysis 107 | run: mix dialyzer --format github --force-check 108 | if: ${{ contains(matrix.elixir, '1.18.0') && contains(matrix.otp, '27.2') }} 109 | -------------------------------------------------------------------------------- /.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 | rewrite-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | !/tmp/.keep 28 | 29 | # dialyzer plt 30 | /test/support/plts/*.plt 31 | /test/support/plts/*.plt.hash 32 | -------------------------------------------------------------------------------- /.iex.exs: -------------------------------------------------------------------------------- 1 | alias Rewrite.Source 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.1.2 - dev 4 | 5 | + Clean up `extra_applications`. 6 | 7 | ## 1.1.1 - 2024/11/15 8 | 9 | + Add option `ignore_missing_sub_formatters` in `Rewrite.DotFormatter.read/2` 10 | and friends. 11 | 12 | ## 1.1.0 - 2024/11/09 13 | 14 | + Added `:exclude` option to `Rewrite.new!/2` and `Rewrite.read!/2`. 15 | 16 | ## 1.0.1 - 2024/11/03 17 | 18 | + Set `locals_for_parens` to `[]` for `Sourceror.to_string/2` if not set, to 19 | prevent Sourceror from trying to fetch `locals_for_parens`. 20 | + Add option `:ignore_unknown_deps` to `DotFormatter.read/2` and friends. 21 | 22 | ## 1.0.0 - 2024/11/01 23 | 24 | ### Breaking changes 25 | 26 | Version `1.0.0` comes with a lot of breaking changes and some improvements. 27 | The main change is to the formatting and handling of `.formatter.exs`. For this 28 | the module `Rewrite.DotFormatter` has been added. This module provides an API to 29 | the Elixir formatting functionality and the formatter configuration via 30 | `.formatter.exs`. 31 | 32 | Other changes concern the argument list of some functions, here some arguments 33 | have been removed from the argument list and moved to the options. 34 | 35 | + `Rewrite.TextDiff` has been moved to its own package 36 | (hex: [text_diff](https://hex.pm/packages/text_diff)). 37 | 38 | + Add `Rewrite.DotFormatter` to handle the formatting of sources and files. 39 | 40 | + The functions `Rewrite.Source.Ex.format/2`, 41 | `Rewrite.Source.Ex.put_formatter_opts/2` and 42 | `Rewrite.Source.Ex.merge_formatter_opts/2` are removed. 43 | The formatting functionality has been moved to `Rewrite.DotFormatter`. 44 | 45 | + Add `Rewrite.create_source/4` to create a `Source` struct without adding it 46 | to the `Rewrite` project. 47 | 48 | + Add `Rewrite.new_source/4` to cretae a `Source` struct and add it to the 49 | `Rewrite` project. 50 | 51 | + The `Rewrite.Source.update/4` function accepts now an updater function or a 52 | value. 53 | 54 | + Add `Rewrite.dot_formatter/1/2` to set and get formatters. 55 | 56 | + Add `Rewrite.format/2` and `Rewrite.fromat!/2` to format a project. 57 | 58 | + Add `Rewrite.format_source/3` to format a source in a project. 59 | 60 | + Add `Rewrite.Hook`, a behaviour to set as `:hooks` in a `%Rewrite{}` project. 61 | 62 | + Add `Rewrite.Source.default_path/0` and callback 63 | `Rewrite.Filetype.default_path/0`. 64 | 65 | + `Rewrite.new/1`, `Rewrite.new!/2` and `Rewrite.read!/3` now expect an optional 66 | options list instead of a list of `Rewrite.Filetype`s. 67 | 68 | + The function `Rewrite.Source.form_string/3` and the callback 69 | `Rewrite.Filetype.from_string/3` are changed to `from_string/2`. The argument 70 | `path` is now part of the options. 71 | 72 | ## 0.10.5 - 2024/06/15 73 | 74 | + Use file extension when filtering the formatter plugins. 75 | 76 | ## 0.10.4 - 2024/06/03 77 | 78 | + Honor `import_deps` in `formatter_opts`. 79 | 80 | ## 0.10.3 - 2024/05/30 81 | 82 | + Include files starting with `.`. 83 | 84 | ## 0.10.2 - 2024/05/27 85 | 86 | + Fix Elixir 1.17 deprecation warning. 87 | 88 | ## 0.10.1 - 2024/04/02 89 | 90 | + Update sourceror version. 91 | 92 | ## 0.10.0 - 2023/11/11 93 | 94 | + Add option `:sync_quoted` to `Source.Ex`. 95 | 96 | ## 0.9.1 - 2023/10/07 97 | 98 | + Read and write files async. 99 | 100 | ## 0.9.0 - 2023/09/15 101 | 102 | + Add options to the list of file types in `Rewrite.new/1` and `Rewrite.read!/2`. 103 | 104 | ## 0.8.0 - 2023/08/27 105 | 106 | + Use `sourceror` version `~> 0.13`. 107 | 108 | ## 0.7.1 - 2023/08/25 109 | 110 | + Update version requirement for `sourceror` to `~> 0.12.0`. 111 | + Add function `Rewrite.Source.issues/1`. 112 | 113 | ## 0.7.0 - 2023/07/17 114 | 115 | ### Breaking Changes 116 | 117 | + The module `Rewrite.Project` moves to `Rewrite`. 118 | 119 | + The `Rewrite.Source.hash` contains the hash of the read in file. The hash can 120 | be used to detect if the file was changed after the last reading. 121 | 122 | + `Rewrite` accetps only `sources` with a valid and unique path. From this, the 123 | handling of conflicting files is no longer part of `rewrite`. 124 | 125 | + `Source.content/2` and `Source.path/2` is replaced by `Source.get/3`. 126 | 127 | + Add `Rewrite.Filetype`. 128 | 129 | ## 0.6.3 - 2023/03/22 130 | 131 | + Fix `Source.format/3`. 132 | 133 | ## 0.6.2 - 2023/03/19 134 | 135 | + Search for `:dot_formatter_opts` in `Source.private` when formatting. 136 | 137 | ## 0.6.1 - 2023/02/26 138 | 139 | + Refactor source formatting. 140 | 141 | ## 0.6.0 - 2023/02/14 142 | 143 | + Add option `:colorizer` to `Rewrite.TextDiff.format/3`. 144 | 145 | ## 0.5.0 - 2023/02/10 146 | 147 | + Update `sourceror` to ~> 0.12. 148 | 149 | + Add `Rewrite.Source.put_private/3`, which allows for storing arbitrary data 150 | on a source. 151 | 152 | ## 0.4.2 - 2023/02/05 153 | 154 | + Add fix for `Rewrite.Source.format/2`. 155 | 156 | + Pin `sourceror` to 0.11.2. 157 | 158 | ## 0.4.1 - 2023/02/04 159 | 160 | + Support the `FreedomFormatter`. 161 | + Update `Rewrite.Source.save/1` to add a neline at the of file. Previously a 162 | newline was added at `Rewrite.Source.update/3`. 163 | 164 | ## 0.4.0 - 2023/02/02 165 | 166 | + Update `Rewrite.TextDiff.format/3` to include more formatting customization. 167 | 168 | ## 0.3.0 - 2022/12/10 169 | 170 | + Accept glob as `%GlobEx{}` as argument for `Rewrite.Project.read!/1` 171 | 172 | ## 0.2.0 - 2022/09/08 173 | 174 | + Remove `Rewrite.Issue`. The type of the field `issues` for `Rewrite.Source` 175 | becomes `[term()]`. 176 | 177 | + Remove `Rewrite.Source.debug_info/2` and `BeamFile` dependency. 178 | 179 | + Add `Rewrite.Project.sources_by_module/2`, `Rewrite.Project.source_by_module/2` 180 | and `RewriteProject.source_by_module!/2`. 181 | 182 | + Remove `Rewrite.Source.zipper/1` 183 | 184 | + Update `Rewrite.Source.update`. An update can now be made with `:path`, `:ast`, 185 | and `:code`. An update with a `Sourceror.Zipper.zipper()` is no longer 186 | supported. 187 | 188 | + Add `Rewrite.Source.from_ast/3`. 189 | 190 | + Add `Rewrite.Source.owner/1`. 191 | 192 | ## 0.1.1 - 2022/09/07 193 | 194 | + Update `Issue.new/4`. 195 | 196 | ## 0.1.0 - 2022/09/05 197 | 198 | + The very first version. 199 | 200 | + This package was previously part of Recode. The extracted modules were also 201 | refactored when they were moved to their own package. 202 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empaty towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at esurk.sucram@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Marcus Kruse 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rewrite 2 | [![Hex.pm: version](https://img.shields.io/hexpm/v/rewrite.svg?style=flat-square)](https://hex.pm/packages/rewrite) 3 | [![GitHub: CI status](https://img.shields.io/github/actions/workflow/status/hrzndhrn/rewrite/ci.yml?branch=main&style=flat-square)](https://github.com/hrzndhrn/rewrite/actions) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://github.com/hrzndhrn//blob/main/LICENSE.md) 5 | 6 | An API for rewriting sources in an Elixir project. Powered by 7 | [`sourceror`](https://github.com/doorgan/sourceror). 8 | 9 | Documentation can be found at [https://hexdocs.pm/rewrite](https://hexdocs.pm/rewrite). 10 | 11 | ## Installation 12 | 13 | The package can be installed by adding `rewrite` to your list of 14 | dependencies in `mix.exs`: 15 | 16 | ```elixir 17 | def deps do 18 | [ 19 | {:rewrite, "~> 1.0"} 20 | ] 21 | end 22 | ``` 23 | -------------------------------------------------------------------------------- /bench/benchee_helper.exs: -------------------------------------------------------------------------------- 1 | Application.ensure_all_started(:rewrite) 2 | BencheeDsl.run() 3 | -------------------------------------------------------------------------------- /bench/rewrite_new_bench.exs: -------------------------------------------------------------------------------- 1 | defmodule RewriteNewBench do 2 | use BencheeDsl.Benchmark 3 | 4 | config time: 20 5 | 6 | before_scenario do 7 | path = "tmp/bench" 8 | File.mkdir_p(path) 9 | File.cd!(path, fn -> 10 | for x <- 1..1_000 do 11 | File.write!("bench_#{x}.ex", ":bench" |> List.duplicate(x) |> Enum.join("\n") ) 12 | end 13 | end) 14 | end 15 | 16 | job new do 17 | Rewrite.new!("tmp/bench/**") 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "coverage_options": { 3 | "treat_no_relevant_lines_as_covered": true, 4 | "minimum_coverage": 90 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /lib/hook/dot_formatter_updater.ex: -------------------------------------------------------------------------------- 1 | defmodule Rewrite.Hook.DotFormatterUpdater do 2 | @moduledoc """ 3 | A hook that updates the dot-formatter for a `%Rewrite{}` project on changes. 4 | """ 5 | 6 | alias Rewrite.DotFormatter 7 | 8 | @behaviour Rewrite.Hook 9 | 10 | @formatter ".formatter.exs" 11 | 12 | @impl true 13 | def handle(:new, project) do 14 | {:ok, %{project | dot_formatter: dot_formatter(project)}} 15 | end 16 | 17 | def handle({action, files}, project) when action in [:added, :updated] do 18 | if dot_formatter?(files) do 19 | {:ok, %{project | dot_formatter: dot_formatter(project)}} 20 | else 21 | :ok 22 | end 23 | end 24 | 25 | defp dot_formatter(project) do 26 | case DotFormatter.read(project) do 27 | {:ok, dot_formatter} -> dot_formatter 28 | {:error, _error} -> DotFormatter.default() 29 | end 30 | end 31 | 32 | defp dot_formatter?(@formatter), do: true 33 | defp dot_formatter?(files) when is_list(files), do: Enum.member?(files, @formatter) 34 | defp dot_formatter?(_files), do: false 35 | end 36 | -------------------------------------------------------------------------------- /lib/rewrite.ex: -------------------------------------------------------------------------------- 1 | defmodule Rewrite do 2 | @moduledoc """ 3 | `Rewrite` is a tool for modifying, adding and removing files in a `Mix` project. 4 | 5 | The package is intended for use in `Mix` tasks. `Rewrite` itself uses functions 6 | provided by `Mix`. 7 | 8 | With `Rewrite.read!/2` you can load the whole project. Then you can modify the 9 | project with a number of functions provided by `Rewrite` and `Rewrite.Source` 10 | without writing any changes back to the file system. All changes are stored in 11 | the source structs. Any version of a source is available in the project. To 12 | write the whole project back to the file system, the `Rewrite.write_all/2` can 13 | be used. 14 | 15 | Elixir source files can be modified by modifying the AST. For this `Rewrite` 16 | uses the `Sourceror` package to create the AST and to convert it back. The 17 | `Sourceror` package also provides all the utilities needed to manipulate the 18 | AST. 19 | 20 | Sources can also receive a `Rewrite.Issue` to document problems or information 21 | with the source. 22 | 23 | `Rewrite` respects the `.formatter.exs` in the project when rewriting sources. 24 | To do this, the formatter can be read by `Rewrite.DotFormatter` and the 25 | resulting DotFormatter struct can be used in the function to update the 26 | sources. 27 | """ 28 | 29 | alias Rewrite.DotFormatter 30 | alias Rewrite.Error 31 | alias Rewrite.Source 32 | alias Rewrite.SourceError 33 | alias Rewrite.UpdateError 34 | 35 | defstruct sources: %{}, 36 | extensions: %{}, 37 | hooks: [], 38 | dot_formatter: nil, 39 | excluded: [] 40 | 41 | @type t :: %Rewrite{ 42 | sources: %{Path.t() => Source.t()}, 43 | extensions: %{String.t() => [module()]}, 44 | hooks: [module()], 45 | dot_formatter: DotFormatter.t() | nil, 46 | excluded: [Path.t()] 47 | } 48 | 49 | @type input :: Path.t() | wildcard() | GlobEx.t() 50 | @type wildcard :: IO.chardata() 51 | @type opts :: keyword() 52 | @type by :: module() 53 | @type key :: atom() 54 | @type updater :: (term() -> term()) 55 | 56 | @doc """ 57 | Creates an empty project. 58 | 59 | ## Options 60 | 61 | * `:filetypes` - a list of modules implementing the behavior 62 | `Rewrite.Filetype`. This list is used to add the `filetype` to the 63 | `sources` of the corresponding files. The list can contain modules 64 | representing a file type or a tuple of `{module(), keyword()}`. Rewrite 65 | uses the keyword list from the tuple as the options argument when a file 66 | is read. 67 | 68 | Defaults to `[Rewrite.Source, Rewrite.Source.Ex]`. 69 | 70 | * `:dot_formatter` - a `%DotFormatter{}` that is used to format sources. 71 | To get and update a dot formatter see `dot_formatter/2` and to create one 72 | see `Rewrite.DotFormatter`. 73 | 74 | ## Examples 75 | 76 | iex> project = Rewrite.new() 77 | iex> path = "test/fixtures/source/hello.txt" 78 | iex> project = Rewrite.read!(project, path) 79 | iex> project |> Rewrite.source!(path) |> Source.get(:content) 80 | "hello\\n" 81 | iex> project |> Rewrite.source!(path) |> Source.owner() 82 | Rewrite 83 | 84 | iex> project = Rewrite.new(filetypes: [{Rewrite.Source, owner: MyApp}]) 85 | iex> path = "test/fixtures/source/hello.txt" 86 | iex> project = Rewrite.read!(project, path) 87 | iex> project |> Rewrite.source!(path) |> Source.owner() 88 | MyApp 89 | """ 90 | @spec new(keyword()) :: t() 91 | def new(opts \\ []) do 92 | Rewrite 93 | |> struct!( 94 | extensions: extensions(opts), 95 | hooks: Keyword.get(opts, :hooks, []) 96 | ) 97 | |> dot_formatter(Keyword.get(opts, :dot_formatter)) 98 | |> handle_hooks(:new) 99 | end 100 | 101 | @doc """ 102 | Creates a `%Rewrite{}` from the given `inputs`. 103 | 104 | ## Options 105 | 106 | * Accepts the same options as `new/1`. 107 | 108 | * 'exclude' - a list of paths and/or glob expressions to exclude sources 109 | from the project. The option also accepts a predicate function which is 110 | called for each source path. The exclusion takes place before the file is 111 | read. 112 | """ 113 | @spec new!(input() | [input()], opts) :: t() 114 | def new!(inputs, opts \\ []) do 115 | opts |> new() |> read!(inputs, opts) 116 | end 117 | 118 | @doc """ 119 | Reads the given `input`/`inputs` and adds the source/sources to the `project` 120 | when not already readed. 121 | 122 | ## Options 123 | 124 | * `:force`, default: `false` - forces the reading of sources. With 125 | `force: true` updates and issues for an already existing source are 126 | deleted. 127 | 128 | * `:exclude` - a list of paths and/or glob expressions to exclude sources 129 | from the project. The option also accepts a predicate function which is 130 | called for each source path. The exclusion takes place before the file is 131 | read. 132 | """ 133 | @spec read!(t(), input() | [input()], opts()) :: t() 134 | def read!(%Rewrite{} = rewrite, inputs, opts \\ []) do 135 | reader = rewrite.sources |> Map.keys() |> reader(rewrite.extensions, opts) 136 | 137 | inputs = expand(inputs) 138 | 139 | {added, excluded, sources} = 140 | Rewrite.TaskSupervisor 141 | |> Task.Supervisor.async_stream_nolink(inputs, reader) 142 | |> Enum.reduce({[], [], rewrite.sources}, fn 143 | {:ok, {path, :excluded}}, {added, excluded, sources} -> 144 | {added, [path | excluded], sources} 145 | 146 | {:ok, {path, source}}, {added, excluded, sources} -> 147 | {[path | added], excluded, Map.put(sources, path, source)} 148 | 149 | {:exit, {error, _stacktrace}}, _sources when is_exception(error) -> 150 | raise error 151 | end) 152 | 153 | rewrite 154 | |> Map.put(:sources, sources) 155 | |> Map.update!(:excluded, fn list -> list |> Enum.concat(excluded) |> Enum.uniq() end) 156 | |> handle_hooks({:added, added}) 157 | end 158 | 159 | defp reader(paths, extensions, opts) do 160 | force = Keyword.get(opts, :force, false) 161 | exclude? = opts |> Keyword.get(:exclude) |> exclude() 162 | 163 | fn path -> 164 | Logger.disable(self()) 165 | 166 | if exclude?.(path) || File.dir?(path) || (!force && path in paths) do 167 | {path, :excluded} 168 | else 169 | source = read_source!(path, extensions) 170 | {path, source} 171 | end 172 | end 173 | end 174 | 175 | defp exclude(nil) do 176 | fn _path -> false end 177 | end 178 | 179 | defp exclude(list) when is_list(list) do 180 | globs = 181 | Enum.map(list, fn 182 | %GlobEx{} = glob -> glob 183 | path -> GlobEx.compile!(path) 184 | end) 185 | 186 | fn path -> 187 | Enum.any?(globs, fn glob -> GlobEx.match?(glob, path) end) 188 | end 189 | end 190 | 191 | defp exclude(fun) when is_function(fun, 1), do: fun 192 | 193 | defp read_source!(path, extensions) when not is_nil(path) do 194 | {source, opts} = extension_for_file(extensions, path) 195 | 196 | source.read!(path, opts) 197 | end 198 | 199 | @doc """ 200 | Returns the extension of the given `file`. 201 | """ 202 | @spec extension_for_file(t() | map(), Path.t() | nil) :: {module(), opts()} 203 | def extension_for_file(%Rewrite{extensions: extensions}, path) do 204 | extension_for_file(extensions, path) 205 | end 206 | 207 | def extension_for_file(extensions, path) do 208 | ext = if path, do: Path.extname(path) 209 | default = Map.fetch!(extensions, "default") 210 | 211 | case Map.get(extensions, ext, default) do 212 | {module, opts} -> {module, opts} 213 | module -> {module, []} 214 | end 215 | end 216 | 217 | @doc """ 218 | Puts the given `source` to the given `rewrite` project. 219 | 220 | Returns `{:ok, rewrite}` if successful, `{:error, reason}` otherwise. 221 | 222 | ## Examples 223 | 224 | iex> project = Rewrite.new() 225 | iex> {:ok, project} = Rewrite.put(project, Source.from_string(":a", path: "a.exs")) 226 | iex> map_size(project.sources) 227 | 1 228 | iex> Rewrite.put(project, Source.from_string(":b")) 229 | {:error, %Rewrite.Error{reason: :nopath}} 230 | iex> Rewrite.put(project, Source.from_string(":a", path: "a.exs")) 231 | {:error, %Rewrite.Error{reason: :overwrites, path: "a.exs"}} 232 | """ 233 | @spec put(t(), Source.t()) :: {:ok, t()} | {:error, Error.t()} 234 | def put(%Rewrite{}, %Source{path: nil}), do: {:error, Error.exception(reason: :nopath)} 235 | 236 | def put(%Rewrite{sources: sources} = rewrite, %Source{path: path} = source) do 237 | case Map.has_key?(sources, path) do 238 | true -> 239 | {:error, Error.exception(reason: :overwrites, path: path)} 240 | 241 | false -> 242 | rewrite = %{rewrite | sources: Map.put(sources, path, source)} 243 | rewrite = handle_hooks(rewrite, {:added, [path]}) 244 | 245 | {:ok, rewrite} 246 | end 247 | end 248 | 249 | @doc """ 250 | Same as `put/2`, but raises a `Rewrite.Error` exception in case of failure. 251 | """ 252 | @spec put!(t(), Source.t()) :: t() 253 | def put!(%Rewrite{} = rewrite, %Source{} = source) do 254 | case put(rewrite, source) do 255 | {:ok, rewrite} -> rewrite 256 | {:error, error} -> raise error 257 | end 258 | end 259 | 260 | @doc """ 261 | Deletes the source for the given `path` from the `rewrite`. 262 | 263 | The file system files are not removed, even if the project is written. Use 264 | `rm/2` or `rm!/2` to delete a file and source. 265 | 266 | If the source is not part of the `rewrite` project the unchanged `rewrite` is 267 | returned. 268 | 269 | ## Examples 270 | 271 | iex> {:ok, project} = Rewrite.from_sources([ 272 | ...> Source.from_string(":a", path: "a.exs"), 273 | ...> Source.from_string(":b", path: "b.exs"), 274 | ...> Source.from_string(":a", path: "c.exs") 275 | ...> ]) 276 | iex> Rewrite.paths(project) 277 | ["a.exs", "b.exs", "c.exs"] 278 | iex> project = Rewrite.delete(project, "a.exs") 279 | iex> Rewrite.paths(project) 280 | ["b.exs", "c.exs"] 281 | iex> project = Rewrite.delete(project, "b.exs") 282 | iex> Rewrite.paths(project) 283 | ["c.exs"] 284 | iex> project = Rewrite.delete(project, "b.exs") 285 | iex> Rewrite.paths(project) 286 | ["c.exs"] 287 | """ 288 | @spec delete(t(), Path.t()) :: t() 289 | def delete(%Rewrite{sources: sources} = rewrite, path) when is_binary(path) do 290 | %{rewrite | sources: Map.delete(sources, path)} 291 | end 292 | 293 | @doc """ 294 | Drops the sources with the given `paths` from the `rewrite` project. 295 | 296 | The file system files are not removed, even if the project is written. Use 297 | `rm/2` or `rm!/2` to delete a file and source. 298 | 299 | If `paths` contains paths that are not in `rewrite`, they're simply ignored. 300 | 301 | ## Examples 302 | 303 | iex> {:ok, project} = Rewrite.from_sources([ 304 | ...> Source.from_string(":a", path: "a.exs"), 305 | ...> Source.from_string(":b", path: "b.exs"), 306 | ...> Source.from_string(":a", path: "c.exs") 307 | ...> ]) 308 | iex> project = Rewrite.drop(project, ["a.exs", "b.exs", "z.exs"]) 309 | iex> Rewrite.paths(project) 310 | ["c.exs"] 311 | """ 312 | @spec drop(t(), [Path.t()]) :: t() 313 | def drop(%Rewrite{} = rewrite, paths) when is_list(paths) do 314 | Enum.reduce(paths, rewrite, fn source, rewrite -> delete(rewrite, source) end) 315 | end 316 | 317 | @doc """ 318 | Tries to delete the `source` file in the file system and removes the `source` 319 | from the `rewrite` project. 320 | 321 | Returns `{:ok, rewrite}` if successful, or `{:error, error}` if an error 322 | occurs. 323 | 324 | Note the file is deleted even if in read-only mode. 325 | """ 326 | @spec rm(t(), Source.t() | Path.t()) :: 327 | {:ok, t()} | {:error, Error.t() | SourceError.t()} 328 | def rm(%Rewrite{} = rewrite, %Source{} = source) do 329 | with :ok <- Source.rm(source) do 330 | {:ok, delete(rewrite, source.path)} 331 | end 332 | end 333 | 334 | def rm(%Rewrite{} = rewrite, source) when is_binary(source) do 335 | with {:ok, source} <- source(rewrite, source) do 336 | rm(rewrite, source) 337 | end 338 | end 339 | 340 | @doc """ 341 | Same as `source/2`, but raises a `Rewrite.Error` exception in case of failure. 342 | """ 343 | @spec rm!(t(), Source.t() | Path.t()) :: t() 344 | def rm!(%Rewrite{} = rewrite, source) when is_binary(source) or is_struct(source, Source) do 345 | case rm(rewrite, source) do 346 | {:ok, rewrite} -> rewrite 347 | {:error, error} -> raise error 348 | end 349 | end 350 | 351 | @doc """ 352 | Moves a source from one path to another. 353 | """ 354 | @spec move(t(), Source.t() | Path.t(), Path.t(), module()) :: {:ok, t()} | {:error, term()} 355 | def move(rewrite, from, to, by \\ Rewrite) 356 | 357 | def move(%Rewrite{} = rewrite, from, to, by) 358 | when is_struct(from, Source) and is_binary(to) and is_atom(by) do 359 | case Map.has_key?(rewrite.sources, to) do 360 | true -> 361 | {:error, UpdateError.exception(reason: :overwrites, path: to, source: from)} 362 | 363 | false -> 364 | update(rewrite, from.path, fn source -> 365 | Source.update(source, :path, to, by: by) 366 | end) 367 | end 368 | end 369 | 370 | def move(%Rewrite{} = rewrite, from, to, by) 371 | when is_binary(from) and is_binary(to) and is_atom(by) do 372 | with {:ok, source} <- source(rewrite, from) do 373 | move(rewrite, source, to, by) 374 | end 375 | end 376 | 377 | @doc """ 378 | Same as `move/4`, but raises an exception in case of failure. 379 | """ 380 | @spec move!(t(), Source.t() | Path.t(), Path.t(), module()) :: t() 381 | def move!(%Rewrite{} = rewrite, from, to, by \\ Rewrite) do 382 | case move(rewrite, from, to, by) do 383 | {:ok, rewrite} -> rewrite 384 | {:error, error} -> raise error 385 | end 386 | end 387 | 388 | @doc """ 389 | Returns a sorted list of all paths in the `rewrite` project. 390 | """ 391 | @spec paths(t()) :: [Path.t()] 392 | def paths(%Rewrite{sources: sources}) do 393 | sources |> Map.keys() |> Enum.sort() 394 | end 395 | 396 | @doc """ 397 | Returns `true` if any source in the `rewrite` project returns `true` for 398 | `Source.updated?/1`. 399 | 400 | ## Examples 401 | 402 | iex> {:ok, project} = Rewrite.from_sources([ 403 | ...> Source.Ex.from_string(":a", path: "a.exs"), 404 | ...> Source.Ex.from_string(":b", path: "b.exs"), 405 | ...> Source.Ex.from_string("c", path: "c.txt") 406 | ...> ]) 407 | iex> Rewrite.updated?(project) 408 | false 409 | iex> project = Rewrite.update!(project, "a.exs", fn source -> 410 | ...> Source.update(source, :quoted, ":z") 411 | ...> end) 412 | iex> Rewrite.updated?(project) 413 | true 414 | """ 415 | @spec updated?(t()) :: boolean() 416 | def updated?(%Rewrite{} = rewrite) do 417 | rewrite.sources |> Map.values() |> Enum.any?(fn source -> Source.updated?(source) end) 418 | end 419 | 420 | @doc ~S""" 421 | Creates a `%Rewrite{}` from the given sources. 422 | 423 | Returns `{:ok, rewrite}` for a list of regular sources. 424 | 425 | Returns `{:error, error}` for sources with a missing path and/or duplicated 426 | paths. 427 | """ 428 | @spec from_sources([Source.t()], opts()) :: {:ok, t()} | {:error, term()} 429 | def from_sources(sources, opts \\ []) when is_list(sources) do 430 | {sources, missing, duplicated} = 431 | Enum.reduce(sources, {%{}, [], []}, fn %Source{} = source, {sources, missing, duplicated} -> 432 | cond do 433 | is_nil(source.path) -> 434 | {sources, [source | missing], duplicated} 435 | 436 | Map.has_key?(sources, source.path) -> 437 | {sources, missing, [source | duplicated]} 438 | 439 | true -> 440 | {Map.put(sources, source.path, source), missing, duplicated} 441 | end 442 | end) 443 | 444 | if Enum.empty?(missing) && Enum.empty?(duplicated) do 445 | rewrite = new(opts) 446 | 447 | rewrite = %{rewrite | sources: sources} 448 | 449 | rewrite = 450 | rewrite 451 | |> Map.put(:sources, sources) 452 | |> handle_hooks({:added_sources, sources}) 453 | 454 | {:ok, rewrite} 455 | else 456 | {:error, 457 | Error.exception( 458 | reason: :invalid_sources, 459 | missing_paths: missing, 460 | duplicated_paths: duplicated 461 | )} 462 | end 463 | end 464 | 465 | @doc """ 466 | Same as `from_sources/2`, but raises a `Rewrite.Error` exception in case of 467 | failure. 468 | """ 469 | @spec from_sources!([Source.t()], opts()) :: t() 470 | def from_sources!(sources, opts \\ []) when is_list(sources) do 471 | case from_sources(sources, opts) do 472 | {:ok, rewrite} -> rewrite 473 | {:error, error} -> raise error 474 | end 475 | end 476 | 477 | @doc """ 478 | Returns all sources sorted by path. 479 | """ 480 | @spec sources(t()) :: [Source.t()] 481 | def sources(%Rewrite{sources: sources}) do 482 | sources 483 | |> Map.values() 484 | |> Enum.sort_by(fn source -> source.path end) 485 | end 486 | 487 | @doc """ 488 | Returns the `%Rewrite.Source{}` for the given `path`. 489 | 490 | Returns an `:ok` tuple with the found source, if not exactly one source is 491 | available an `:error` is returned. 492 | 493 | See also `sources/2` to get a list of sources for a given `path`. 494 | """ 495 | @spec source(t(), Path.t()) :: {:ok, Source.t()} | {:error, Error.t()} 496 | def source(%Rewrite{sources: sources}, path) when is_binary(path) do 497 | with :error <- Map.fetch(sources, path) do 498 | {:error, Error.exception(reason: :nosource, path: path)} 499 | end 500 | end 501 | 502 | @doc """ 503 | Same as `source/2`, but raises a `Rewrite.Error` exception in case of 504 | failure. 505 | """ 506 | @spec source!(t(), Path.t()) :: Source.t() 507 | def source!(%Rewrite{} = rewrite, path) do 508 | case source(rewrite, path) do 509 | {:ok, source} -> source 510 | {:error, error} -> raise error 511 | end 512 | end 513 | 514 | @doc """ 515 | Updates the given `source` in the `rewrite` project. 516 | 517 | This function will be usually used if the `path` for the `source` has not 518 | changed. 519 | 520 | Returns `{:ok, rewrite}` if successful, `{:error, error}` otherwise. 521 | """ 522 | @spec update(t(), Source.t()) :: 523 | {:ok, t()} | {:error, Error.t()} 524 | def update(%Rewrite{}, %Source{path: nil}), 525 | do: {:error, Error.exception(reason: :nopath)} 526 | 527 | def update(%Rewrite{} = rewrite, %Source{} = source) do 528 | update(rewrite, source.path, source) 529 | end 530 | 531 | @doc """ 532 | The same as `update/2` but raises a `Rewrite.Error` exception in case 533 | of an error. 534 | """ 535 | @spec update!(t(), Source.t()) :: t() 536 | def update!(%Rewrite{} = rewrite, %Source{} = source) do 537 | case update(rewrite, source) do 538 | {:ok, rewrite} -> rewrite 539 | {:error, error} -> raise error 540 | end 541 | end 542 | 543 | @doc """ 544 | Updates a source for the given `path` in the `rewrite` project. 545 | 546 | If `source` a `Rewrite.Source` struct the struct is used to update the 547 | `rewrite` project. 548 | 549 | If `source` is a function the source for the given `path` is passed to the 550 | function and the result is used to update the `rewrite` project. 551 | 552 | Returns `{:ok, rewrite}` if the update was successful, `{:error, error}` 553 | otherwise. 554 | 555 | ## Examples 556 | 557 | iex> a = Source.Ex.from_string(":a", path: "a.exs") 558 | iex> b = Source.Ex.from_string(":b", path: "b.exs") 559 | iex> {:ok, project} = Rewrite.from_sources([a, b]) 560 | iex> {:ok, project} = Rewrite.update(project, "a.exs", Source.Ex.from_string(":foo", path: "a.exs")) 561 | iex> project |> Rewrite.source!("a.exs") |> Source.get(:content) 562 | ":foo" 563 | iex> {:ok, project} = Rewrite.update(project, "a.exs", fn s -> Source.update(s, :content, ":baz") end) 564 | iex> project |> Rewrite.source!("a.exs") |> Source.get(:content) 565 | ":baz" 566 | iex> {:ok, project} = Rewrite.update(project, "a.exs", fn s -> Source.update(s, :path, "c.exs") end) 567 | iex> Rewrite.paths(project) 568 | ["b.exs", "c.exs"] 569 | iex> Rewrite.update(project, "no.exs", Source.from_string(":foo", path: "x.exs")) 570 | {:error, %Rewrite.Error{reason: :nosource, path: "no.exs"}} 571 | iex> Rewrite.update(project, "c.exs", Source.from_string(":foo")) 572 | {:error, %Rewrite.UpdateError{reason: :nopath, source: "c.exs"}} 573 | iex> Rewrite.update(project, "c.exs", fn _ -> b end) 574 | {:error, %Rewrite.UpdateError{reason: :overwrites, path: "b.exs", source: "c.exs"}} 575 | """ 576 | @spec update(t(), Path.t(), Source.t() | function()) :: 577 | {:ok, t()} | {:error, Error.t() | UpdateError.t()} 578 | def update(%Rewrite{}, path, %Source{path: nil}) when is_binary(path) do 579 | {:error, UpdateError.exception(reason: :nopath, source: path)} 580 | end 581 | 582 | def update(%Rewrite{} = rewrite, path, %Source{} = source) 583 | when is_binary(path) do 584 | with {:ok, _stored} <- source(rewrite, path) do 585 | do_update(rewrite, path, source) 586 | end 587 | end 588 | 589 | def update(%Rewrite{} = rewrite, path, fun) when is_binary(path) and is_function(fun, 1) do 590 | with {:ok, stored} <- source(rewrite, path), 591 | {:ok, source} <- apply_update!(stored, fun) do 592 | do_update(rewrite, path, source) 593 | end 594 | end 595 | 596 | defp do_update(rewrite, path, source) do 597 | case path == source.path do 598 | true -> 599 | rewrite = %{rewrite | sources: Map.put(rewrite.sources, path, source)} 600 | rewrite = handle_hooks(rewrite, {:updated, path}) 601 | {:ok, rewrite} 602 | 603 | false -> 604 | case Map.has_key?(rewrite.sources, source.path) do 605 | true -> 606 | {:error, UpdateError.exception(reason: :overwrites, path: source.path, source: path)} 607 | 608 | false -> 609 | sources = rewrite.sources |> Map.delete(path) |> Map.put(source.path, source) 610 | {:ok, %{rewrite | sources: sources}} 611 | end 612 | end 613 | end 614 | 615 | defp apply_update!(source, fun) do 616 | case fun.(source) do 617 | %Source{path: nil} -> 618 | {:error, UpdateError.exception(reason: :nopath, source: source.path)} 619 | 620 | %Source{} = source -> 621 | {:ok, source} 622 | 623 | got -> 624 | raise RuntimeError, """ 625 | expected %Source{} from anonymous function given to Rewrite.update/3, got: #{inspect(got)}\ 626 | """ 627 | end 628 | end 629 | 630 | @doc """ 631 | The same as `update/3` but raises a `Rewrite.Error` exception in case 632 | of an error. 633 | """ 634 | @spec update!(t(), Path.t(), Source.t() | function()) :: t() 635 | def update!(%Rewrite{} = rewrite, path, new) when is_binary(path) do 636 | case update(rewrite, path, new) do 637 | {:ok, rewrite} -> rewrite 638 | {:error, error} -> raise error 639 | end 640 | end 641 | 642 | @doc """ 643 | Updates the source for the given `path` and `key` with the given `fun`. 644 | 645 | The function combines `update/3` and `Source.update/4` in one call. 646 | 647 | ## Examples 648 | 649 | iex> project = 650 | ...> Rewrite.new() 651 | ...> |> Rewrite.new_source!("test.md", "foo") 652 | ...> |> Rewrite.update_source!("test.md", :content, fn content -> 653 | ...> content <> "bar" 654 | ...> end) 655 | ...> |> Rewrite.update_source!("test.md", :content, &String.upcase/1, by: MyApp) 656 | iex> source = Rewrite.source!(project, "test.md") 657 | iex> source.content 658 | "FOOBAR" 659 | iex> source.history 660 | [{:content, MyApp, "foobar"}, {:content, Rewrite, "foo"}] 661 | """ 662 | @spec update_source(t(), Path.t(), key(), updater(), opts()) :: 663 | {:ok, t()} | {:error, term()} 664 | def update_source(%Rewrite{} = rewrite, path, key, fun, opts \\ []) do 665 | update(rewrite, path, fn source -> 666 | Source.update(source, key, fun, opts) 667 | end) 668 | end 669 | 670 | @doc """ 671 | The same as `update_source/5` but raises a `Rewrite.Error` exception in case 672 | of an error. 673 | """ 674 | @spec update_source!(t(), Path.t(), key(), updater(), opts()) :: t() 675 | def update_source!(%Rewrite{} = rewrite, path, key, fun, opts \\ []) do 676 | case update_source(rewrite, path, key, fun, opts) do 677 | {:ok, rewrite} -> rewrite 678 | {:error, error} -> raise error 679 | end 680 | end 681 | 682 | @doc """ 683 | Returns `true` when the `%Rewrite{}` contains a `%Source{}` with the given 684 | `path`. 685 | 686 | ## Examples 687 | 688 | iex> {:ok, project} = Rewrite.from_sources([ 689 | ...> Source.from_string(":a", path: "a.exs") 690 | ...> ]) 691 | iex> Rewrite.has_source?(project, "a.exs") 692 | true 693 | iex> Rewrite.has_source?(project, "b.exs") 694 | false 695 | """ 696 | @spec has_source?(t(), Path.t()) :: boolean() 697 | def has_source?(%Rewrite{sources: sources}, path) when is_binary(path) do 698 | Map.has_key?(sources, path) 699 | end 700 | 701 | @doc """ 702 | Returns `true` if any source has one or more issues. 703 | """ 704 | @spec issues?(t) :: boolean 705 | def issues?(%Rewrite{sources: sources}) do 706 | sources 707 | |> Map.values() 708 | |> Enum.any?(fn %Source{issues: issues} -> not Enum.empty?(issues) end) 709 | end 710 | 711 | @doc """ 712 | Counts the sources with the given `extname` in the `rewrite` project. 713 | """ 714 | @spec count(t, String.t()) :: non_neg_integer 715 | def count(%Rewrite{sources: sources}, extname) when is_binary(extname) do 716 | sources 717 | |> Map.keys() 718 | |> Enum.count(fn path -> Path.extname(path) == extname end) 719 | end 720 | 721 | @doc """ 722 | Invokes `fun` for each `source` in the `rewrite` project and updates the 723 | `rewirte` project with the result of `fun`. 724 | 725 | Returns a `{:ok, rewrite}` if any update is successful. 726 | 727 | Returns `{:error, errors, rewrite}` where `rewrite` is updated for all sources 728 | that are updated successful. The `errors` are the `errors` of `update/3`. 729 | """ 730 | @spec map(t(), (Source.t() -> Source.t())) :: 731 | {:ok, t()} | {:error, [{:nosource | :overwrites | :nopath, Source.t()}]} 732 | def map(%Rewrite{} = rewrite, fun) when is_function(fun, 1) do 733 | {rewrite, errors} = 734 | Enum.reduce(rewrite, {rewrite, []}, fn source, {rewrite, errors} -> 735 | with {:ok, updated} <- apply_update!(source, fun), 736 | {:ok, rewrite} <- do_update(rewrite, source.path, updated) do 737 | {rewrite, errors} 738 | else 739 | {:error, error} -> {rewrite, [error | errors]} 740 | end 741 | end) 742 | 743 | if Enum.empty?(errors) do 744 | {:ok, rewrite} 745 | else 746 | {:error, errors, rewrite} 747 | end 748 | end 749 | 750 | @doc """ 751 | Return a `rewrite` project where each `source` is the result of invoking 752 | `fun` on each `source` of the given `rewrite` project. 753 | """ 754 | @spec map!(t(), (Source.t() -> Source.t())) :: t() 755 | def map!(%Rewrite{} = rewrite, fun) when is_function(fun, 1) do 756 | Enum.reduce(rewrite, rewrite, fn source, rewrite -> 757 | with {:ok, updated} <- apply_update!(source, fun), 758 | {:ok, rewrite} <- do_update(rewrite, source.path, updated) do 759 | rewrite 760 | else 761 | {:error, error} -> raise error 762 | end 763 | end) 764 | end 765 | 766 | @doc """ 767 | Writes a source to disk. 768 | 769 | The function expects a path or a `%Source{}` as first argument. 770 | 771 | Returns `{:ok, rewrite}` if the file was written successful. See also 772 | `Source.write/2`. 773 | 774 | If the given `source` is not part of the `rewrite` project then it is added. 775 | """ 776 | @spec write(t(), Path.t() | Source.t(), nil | :force) :: 777 | {:ok, t()} | {:error, Error.t() | SourceError.t()} 778 | def write(rewrite, path, force \\ nil) 779 | 780 | def write(%Rewrite{} = rewrite, path, force) when is_binary(path) and force in [nil, :force] do 781 | with {:ok, source} <- source(rewrite, path) do 782 | write(rewrite, source, force) 783 | end 784 | end 785 | 786 | def write(%Rewrite{} = rewrite, %Source{} = source, force) when force in [nil, :force] do 787 | with {:ok, source} <- Source.write(source) do 788 | {:ok, Rewrite.update!(rewrite, source)} 789 | end 790 | end 791 | 792 | @doc """ 793 | The same as `write/3` but raises an exception in case of an error. 794 | """ 795 | @spec write!(t(), Path.t() | Source.t(), nil | :force) :: t() 796 | def write!(%Rewrite{} = rewrite, source, force \\ nil) do 797 | case write(rewrite, source, force) do 798 | {:ok, rewrite} -> rewrite 799 | {:error, error} -> raise error 800 | end 801 | end 802 | 803 | @doc """ 804 | Writes all sources in the `rewrite` project to disk. 805 | 806 | This function calls `Rewrite.Source.write/1` on all sources in the `rewrite` 807 | project. 808 | 809 | Returns `{:ok, rewrite}` if all sources are written successfully. 810 | 811 | Returns `{:error, reasons, rewrite}` where `rewrite` is updated for all 812 | sources that are written successfully. 813 | 814 | ## Options 815 | 816 | + `exclude` - a list paths to exclude form writting. 817 | + `force`, default: `false` - forces the writting of unchanged files. 818 | """ 819 | @spec write_all(t(), opts()) :: 820 | {:ok, t()} | {:error, [SourceError.t()], t()} 821 | def write_all(%Rewrite{} = rewrite, opts \\ []) do 822 | exclude = Keyword.get(opts, :exclude, []) 823 | force = if Keyword.get(opts, :force, false), do: :force, else: nil 824 | 825 | write_all(rewrite, exclude, force) 826 | end 827 | 828 | defp write_all(%Rewrite{sources: sources} = rewrite, exclude, force) 829 | when force in [nil, :force] do 830 | sources = for {path, source} <- sources, path not in exclude, do: source 831 | writer = fn source -> Source.write(source, force: force) end 832 | 833 | {rewrite, errors} = 834 | Rewrite.TaskSupervisor 835 | |> Task.Supervisor.async_stream_nolink(sources, writer) 836 | |> Enum.reduce({rewrite, []}, fn {:ok, result}, {rewrite, errors} -> 837 | case result do 838 | {:ok, source} -> {Rewrite.update!(rewrite, source), errors} 839 | {:error, error} -> {rewrite, [error | errors]} 840 | end 841 | end) 842 | 843 | if Enum.empty?(errors) do 844 | {:ok, rewrite} 845 | else 846 | {:error, errors, rewrite} 847 | end 848 | end 849 | 850 | @doc """ 851 | Formats the given `rewrite` project with the given `dot_formatter`. 852 | 853 | Uses the formatter from `dot_formatter/2` if no formatter ist set by 854 | `:dot_formatter` in the options. The other options are the same as for 855 | `DotFormatter.read!/2`. 856 | """ 857 | @spec format(t(), opts()) :: {:ok, t()} | {:error, term()} 858 | def format(%Rewrite{} = rewrite, opts \\ []) do 859 | dot_formatter = Keyword.get(opts, :dot_formatter, dot_formatter(rewrite)) 860 | DotFormatter.format_rewrite(dot_formatter, rewrite, opts) 861 | end 862 | 863 | @doc """ 864 | The same as `format/2` but raises an exception in case of an error. 865 | """ 866 | @spec format!(t(), opts()) :: t() 867 | def format!(rewrite, opts \\ []) do 868 | case format(rewrite, opts) do 869 | {:ok, rewrite} -> rewrite 870 | {:error, error} -> raise error 871 | end 872 | end 873 | 874 | @doc """ 875 | Formats a source in a `rewrite` project. 876 | 877 | Uses the formatter from `dot_formatter/2` if no formatter ist set by 878 | `:dot_formatter` in the options. The other options are the same as for 879 | `Code.format_string!/2`. 880 | """ 881 | @spec format_source(t(), Path.t() | Source.t(), keyword()) :: {:ok, t()} | {:error, term()} 882 | def format_source(rewrite, file, opts \\ []) 883 | 884 | def format_source(%Rewrite{} = rewrite, %Source{path: path}, opts) when is_binary(path) do 885 | format_source(rewrite, path, opts) 886 | end 887 | 888 | def format_source(%Rewrite{} = rewrite, file, opts) do 889 | dot_formatter = Keyword.get_lazy(opts, :dot_formatter, fn -> dot_formatter(rewrite) end) 890 | DotFormatter.format_source(dot_formatter, rewrite, file, opts) 891 | end 892 | 893 | @doc """ 894 | The same as `format_source/3` but raises an exception in case of an error. 895 | """ 896 | @spec format_source!(t(), Path.t() | Source.t(), keyword()) :: t() 897 | def format_source!(rewrite, file, opts \\ []) do 898 | case format_source(rewrite, file, opts) do 899 | {:ok, source} -> source 900 | {:error, error} -> raise error 901 | end 902 | end 903 | 904 | @doc """ 905 | Returns the `DotFormatter` for the given `rewrite` project. 906 | 907 | When no formatter is set, the default formatter from 908 | `Rewrite.DotFormatter.default/0` is returned. A dot formatter can be set with 909 | `dot_formatter/2`. 910 | """ 911 | @spec dot_formatter(t()) :: DotFormatter.t() 912 | def dot_formatter(%Rewrite{dot_formatter: nil}), do: DotFormatter.default() 913 | def dot_formatter(%Rewrite{dot_formatter: dot_formatter}), do: dot_formatter 914 | 915 | @doc """ 916 | Sets a `dot_formatter` for the given `rewrite` project. 917 | """ 918 | @spec dot_formatter(t(), DotFormatter.t() | nil) :: t() 919 | def dot_formatter(%Rewrite{} = rewrite, dot_formatter) 920 | when is_struct(dot_formatter, DotFormatter) or is_nil(dot_formatter) do 921 | %{rewrite | dot_formatter: dot_formatter} 922 | end 923 | 924 | @doc """ 925 | Creates a new `%Source{}` and puts the source to the `%Rewrite{}` project. 926 | 927 | The `:filetypes` option of the project is used to create the source. If 928 | options have been specified for the file type, the given options will be 929 | merged into those options. 930 | 931 | Use `create_source/4` if the source is not to be inserted directly into the 932 | project. 933 | """ 934 | @spec new_source(t(), Path.t(), String.t(), opts()) :: {:ok, t()} | {:error, Error.t()} 935 | def new_source(%Rewrite{sources: sources} = rewrite, path, content, opts \\ []) 936 | when is_binary(path) do 937 | case Map.has_key?(sources, path) do 938 | true -> 939 | {:error, Error.exception(reason: :overwrites, path: path)} 940 | 941 | false -> 942 | source = create_source(rewrite, path, content, opts) 943 | put(rewrite, source) 944 | end 945 | end 946 | 947 | @doc """ 948 | Same as `new_source/4`, but raises a `Rewrite.Error` exception in case of failure. 949 | """ 950 | @spec new_source!(t(), Path.t(), String.t(), opts()) :: t() 951 | def new_source!(%Rewrite{} = rewrite, path, content, opts \\ []) do 952 | case new_source(rewrite, path, content, opts) do 953 | {:ok, rewrite} -> rewrite 954 | {:error, error} -> raise error 955 | end 956 | end 957 | 958 | @doc """ 959 | Creates a new `%Source{}` without putting it to the `%Rewrite{}` project. 960 | 961 | The `:filetypes` option of the project is used to create the source. If 962 | options have been specified for the file type, the given options will be 963 | merged into those options. If no `path` is given, the default file type is 964 | created. 965 | 966 | The function does not check whether the `%Rewrite{}` project already has a 967 | `%Source{}` with the specified path. 968 | 969 | Use `new_source/4` if the source is to be inserted directly into the project. 970 | """ 971 | @spec create_source(t(), Path.t() | nil, String.t(), opts()) :: Source.t() 972 | def create_source(%Rewrite{} = rewrite, path, content, opts \\ []) do 973 | {source, source_opts} = extension_for_file(rewrite, path) 974 | opts = source_opts |> Keyword.merge(opts) |> Keyword.put(:path, path) 975 | 976 | source.from_string(content, opts) 977 | end 978 | 979 | defp extensions(opts) do 980 | opts 981 | |> Keyword.get(:filetypes, [Source, Source.Ex]) 982 | |> Enum.flat_map(fn 983 | Source -> 984 | [{"default", Source}] 985 | 986 | {Source, opts} -> 987 | [{"default", {Source, opts}}] 988 | 989 | {module, opts} -> 990 | Enum.map(module.extensions(), fn extension -> {extension, {module, opts}} end) 991 | 992 | module -> 993 | Enum.map(module.extensions(), fn extension -> {extension, module} end) 994 | end) 995 | |> Map.new() 996 | |> Map.put_new("default", Source) 997 | end 998 | 999 | defp expand(inputs) do 1000 | inputs 1001 | |> List.wrap() 1002 | |> Stream.map(&compile_globs!/1) 1003 | |> Stream.flat_map(&GlobEx.ls/1) 1004 | |> Stream.uniq() 1005 | end 1006 | 1007 | defp compile_globs!(str) when is_binary(str), do: GlobEx.compile!(str, match_dot: true) 1008 | 1009 | defp compile_globs!(glob) when is_struct(glob, GlobEx), do: glob 1010 | 1011 | defimpl Enumerable do 1012 | def count(rewrite) do 1013 | {:ok, map_size(rewrite.sources)} 1014 | end 1015 | 1016 | def member?(rewrite, %Source{} = source) do 1017 | member? = Map.get(rewrite.sources, source.path) == source 1018 | {:ok, member?} 1019 | end 1020 | 1021 | def member?(_rewrite, _other) do 1022 | {:ok, false} 1023 | end 1024 | 1025 | def slice(rewrite) do 1026 | sources = rewrite.sources |> Map.values() |> Enum.sort_by(fn source -> source.path end) 1027 | length = length(sources) 1028 | 1029 | {:ok, length, 1030 | fn 1031 | start, count when start + count == length -> Enum.drop(sources, start) 1032 | start, count -> sources |> Enum.drop(start) |> Enum.take(count) 1033 | end} 1034 | end 1035 | 1036 | def reduce(rewrite, acc, fun) do 1037 | sources = Map.values(rewrite.sources) 1038 | Enumerable.List.reduce(sources, acc, fun) 1039 | end 1040 | end 1041 | 1042 | defp handle_hooks(%{hooks: []} = rewrite, _action), do: rewrite 1043 | 1044 | defp handle_hooks(rewrite, {:added_sources, sources}) do 1045 | paths = Enum.map(sources, fn {path, _source} -> path end) 1046 | handle_hooks(rewrite, {:added, paths}) 1047 | end 1048 | 1049 | defp handle_hooks(%{hooks: hooks} = rewrite, action) do 1050 | Enum.reduce(hooks, rewrite, fn hook, rewrite -> 1051 | case hook.handle(action, rewrite) do 1052 | :ok -> 1053 | rewrite 1054 | 1055 | {:ok, rewrite} -> 1056 | rewrite 1057 | 1058 | unexpected -> 1059 | raise Error.exception( 1060 | reason: :unexpected_hook_response, 1061 | message: """ 1062 | unexpected response from hook, got: #{inspect(unexpected)}\ 1063 | """ 1064 | ) 1065 | end 1066 | end) 1067 | end 1068 | 1069 | defimpl Inspect do 1070 | def inspect(rewrite, _opts) do 1071 | "#Rewrite<#{Enum.count(rewrite.sources)} source(s)>" 1072 | end 1073 | end 1074 | end 1075 | -------------------------------------------------------------------------------- /lib/rewrite/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Rewrite.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | @impl true 7 | def start(_type, _args) do 8 | children = [ 9 | {Task.Supervisor, name: Rewrite.TaskSupervisor} 10 | ] 11 | 12 | opts = [strategy: :one_for_one, name: Rewrite.Supervisor] 13 | 14 | Supervisor.start_link(children, opts) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/rewrite/dot_formatter_error.ex: -------------------------------------------------------------------------------- 1 | defmodule Rewrite.DotFormatterError do 2 | @moduledoc """ 3 | An exception raised when an error is encountered while working with 4 | dot_formatters. 5 | """ 6 | 7 | defexception [:reason, :path, :not_formatted, :exits] 8 | 9 | @type t :: %{reason: reason(), path: Path.t(), not_formatted: [Path.t()], exits: exits()} 10 | 11 | @type reason :: atom() | {atom(), term()} 12 | @type exits :: term() 13 | 14 | def message(%{reason: {:read, :enoent}, path: path}) do 15 | "Could not read file #{inspect(path)}: no such file or directory" 16 | end 17 | 18 | def message(%{reason: :invalid_term, path: path}) do 19 | "The file #{inspect(path)} does not contain a valid formatter config." 20 | end 21 | 22 | def message(%{reason: :dot_formatter_not_found, path: path}) do 23 | "#{path} not found" 24 | end 25 | 26 | def message(%{reason: {:invalid_subdirectories, subdirectories}, path: path}) do 27 | """ 28 | Expected :subdirectories to return a list of directories, \ 29 | got: #{inspect(subdirectories)}, in: #{inspect(path)}\ 30 | """ 31 | end 32 | 33 | def message(%{reason: {:invalid_import_deps, import_deps}, path: path}) do 34 | """ 35 | Expected :import_deps to return a list of dependencies, \ 36 | got: #{inspect(import_deps)}, in: #{inspect(path)}\ 37 | """ 38 | end 39 | 40 | def message(%{reason: {:invalid_remove_plugins, remove_plugins}}) do 41 | "Expected :remove_plugins to be a list of modules, got: #{inspect(remove_plugins)}" 42 | end 43 | 44 | def message(%{reason: {:invalid_replace_plugins, replace_plugins}}) do 45 | "Expected :replace_plugins to be a list of tuples, got: #{inspect(replace_plugins)}" 46 | end 47 | 48 | def message(%{reason: :no_inputs_or_subdirectories, path: path}) do 49 | "Expected :inputs or :subdirectories key in #{inspect(path)}" 50 | end 51 | 52 | def message(%{reason: {:dep_not_found, dep}}) do 53 | """ 54 | Unknown dependency #{inspect(dep)} given to :import_deps in the formatter \ 55 | configuration. Make sure the dependency is listed in your mix.exs for \ 56 | environment :dev and you have run "mix deps.get"\ 57 | """ 58 | end 59 | 60 | def message(%{reason: :format, not_formatted: not_formatted, exits: exits}) do 61 | not_formatted = Enum.map(not_formatted, fn {file, _input, _formatted} -> file end) 62 | 63 | """ 64 | Format errors - Not formatted: #{inspect(not_formatted)}, Exits: #{inspect(exits)}\ 65 | """ 66 | end 67 | 68 | def message(%{reason: {:conflicts, dot_formatters}}) do 69 | dot_formatters = 70 | Enum.map_join(dot_formatters, "\n", fn {file, formatters} -> 71 | "file: #{inspect(file)}, formatters: #{inspect(formatters)}" 72 | end) 73 | 74 | """ 75 | Multiple formatter files specifying the same file in their :inputs options: 76 | #{dot_formatters}\ 77 | """ 78 | end 79 | 80 | def message(%{reason: %GlobEx.CompileError{} = error}) do 81 | "Invalid glob #{inspect(error.input)}, #{Exception.message(error)}" 82 | end 83 | 84 | def message(%{reason: {:invalid_input, input}}) do 85 | "Invalid input, got: #{inspect(input)}" 86 | end 87 | 88 | def message(%{reason: {:invalid_inputs, inputs}}) do 89 | "Invalid inputs, got: #{inspect(inputs)}" 90 | end 91 | 92 | def message(%{reason: {:invalid_locals_without_parens, locals_without_parens}}) do 93 | "Invalid locals_without_parens, got: #{inspect(locals_without_parens)}" 94 | end 95 | 96 | def message(%{reason: {:no_subs, dirs}}) do 97 | "No sub formatter(s) found in #{inspect(dirs)}" 98 | end 99 | 100 | def message(%{reason: {:missing_subs, dirs}}) do 101 | "Missing sub formatter(s) in #{inspect(dirs)}" 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/rewrite/error.ex: -------------------------------------------------------------------------------- 1 | defmodule Rewrite.Error do 2 | @moduledoc """ 3 | An exception for when a function cannot handle a source. 4 | """ 5 | 6 | alias Rewrite.Error 7 | alias Rewrite.Source 8 | 9 | @type reason :: :nosource | :nopath | :overwrites | :invalid_sources 10 | 11 | @type t :: %Error{ 12 | reason: reason, 13 | path: Path.t() | nil, 14 | missing_paths: [Source.t()] | nil, 15 | duplicated_paths: [Source.t()] | nil, 16 | message: String.t() | nil 17 | } 18 | 19 | @enforce_keys [:reason] 20 | defexception [:reason, :path, :missing_paths, :duplicated_paths, :message] 21 | 22 | @impl true 23 | def exception(value) do 24 | struct!(Error, value) 25 | end 26 | 27 | @impl true 28 | def message(%Error{message: message}) when is_binary(message) do 29 | message 30 | end 31 | 32 | def message(%Error{reason: :nopath}) do 33 | "no path found" 34 | end 35 | 36 | def message(%Error{reason: :nosource, path: path}) do 37 | "no source found for #{inspect(path)}" 38 | end 39 | 40 | def message(%Error{reason: :overwrites, path: path}) do 41 | "overwrites #{inspect(path)}" 42 | end 43 | 44 | def message(%Error{reason: :invalid_sources}) do 45 | "invalid sources" 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/rewrite/filetype.ex: -------------------------------------------------------------------------------- 1 | defmodule Rewrite.Filetype do 2 | @moduledoc """ 3 | The behaviour for filetypes. 4 | 5 | An implementation of the filetype behaviour extends a source. For an example, 6 | see `Rewrite.Source.Ex`. 7 | """ 8 | 9 | alias Rewrite.Source 10 | 11 | @type t :: map() 12 | @type updates :: keyword() 13 | @type key :: atom() 14 | @type value :: term() 15 | @type updater :: (term() -> term()) 16 | @type extension :: String.t() 17 | @type opts :: keyword() 18 | 19 | @doc """ 20 | Returns a list of file type extensions for which the module is responsible. 21 | """ 22 | @callback extensions :: [extension] | :any 23 | 24 | @doc """ 25 | Returns the default path for the `filetype`. 26 | """ 27 | @callback default_path :: Path.t() 28 | 29 | @doc """ 30 | Returns a `Rewrite.Source` with a `filetype` from the given `string`. 31 | """ 32 | @callback from_string(string :: Source.content()) :: Source.t() 33 | @doc """ 34 | Returns a `Rewrite.Source` with a `filetype` form the given, `string` and `options`. 35 | """ 36 | @callback from_string(string :: Source.content(), opts()) :: Source.t() 37 | 38 | @doc """ 39 | Returns a `Rewrite.Source` with a `filetype` from a file. 40 | """ 41 | @callback read!(path :: Path.t()) :: Source.t() 42 | @doc """ 43 | Returns a `Rewrite.Source` with a `filetype` from a file. 44 | """ 45 | @callback read!(path :: Path.t(), opts()) :: Source.t() 46 | 47 | @doc """ 48 | This function is called after an undo of the `source`. 49 | """ 50 | @callback undo(source :: Source.t()) :: Source.t() 51 | 52 | @doc """ 53 | This function is called when the content or path of the `source` is updated. 54 | 55 | Returns a `%Source{}` with an updated `filetype`. 56 | """ 57 | @callback handle_update(source :: Source.t(), key(), opts()) :: t() 58 | 59 | @doc """ 60 | This function is called when the `source` is updated by a `key` that is 61 | handled by the current `filetype`. 62 | 63 | Returns a keyword with the keys `:content` and `:filetype` to update the 64 | `source`. 65 | """ 66 | @callback handle_update(source :: Source.t(), key(), value() | updater(), opts()) :: updates() 67 | 68 | @doc """ 69 | Fetches the value for a specific `key` for the given `source`. 70 | 71 | If `source` contains the given `key` then its value is returned in the shape 72 | of {:ok, value}. If `source` doesn't contain key, :error is returned. 73 | """ 74 | @callback fetch(source :: Source.t(), key()) :: value() 75 | 76 | @doc """ 77 | Fetches the value for a specific `key` in a `source` for the given `version`. 78 | 79 | If `source` contains the given `key` then its value is returned in the shape 80 | of {:ok, value}. If `source` doesn't contain key, :error is returned. 81 | """ 82 | @callback fetch(source :: Source.t(), key(), version :: Source.version()) :: value() 83 | end 84 | -------------------------------------------------------------------------------- /lib/rewrite/hook.ex: -------------------------------------------------------------------------------- 1 | defmodule Rewrite.Hook do 2 | @moduledoc """ 3 | A `behaviour` for hooking into the `Rewrite` processes. 4 | 5 | The callback `c:handle/2` is called by `Rewrite` with an `t:action/0` and the 6 | current `%Rewrite{}`. The return value is either `:ok` or `{:ok, rewrite}`. 7 | 8 | > #### Warning {: .warning} 9 | > If the `%Rewrite{}` project is updated inside the hook, the hook will be 10 | > called again. 11 | 12 | ## Actions 13 | 14 | * `:new` - invoked when a new `%Rewrite{}` is created. 15 | 16 | * `{:added, paths}` - invoked when new sources were added. `paths` is the a 17 | list of `t:Path.t()`. 18 | 19 | * `{:updated, path}` - invoked when a source was updated. `path` contains 20 | the path of the updated source. Also called when a source was succesfull 21 | formatted. 22 | 23 | """ 24 | 25 | @type action :: atom() | {atom(), Path.t() | [Path.t()]} 26 | 27 | @callback handle(action(), rewrite :: Rewrite.t()) :: :ok | {:ok, Rewrite.t()} 28 | end 29 | -------------------------------------------------------------------------------- /lib/rewrite/source.ex: -------------------------------------------------------------------------------- 1 | defmodule Rewrite.Source do 2 | @moduledoc """ 3 | A representation of some source in a project. 4 | 5 | The `%Source{}` contains the `content` of the file given by `path`. The module 6 | contains `update/3` to update the `path` and/or the `content`. The changes are 7 | recorded in the `history` list. 8 | 9 | The struct also holds `issues` for the source. 10 | 11 | The different versions of `content` and `path` are available via `get/3`. 12 | 13 | A source is extensible via `filetype`, see `Rewrite.Filetype`. 14 | """ 15 | 16 | alias Rewrite.DotFormatter 17 | alias Rewrite.Source 18 | alias Rewrite.SourceError 19 | alias Rewrite.SourceKeyError 20 | 21 | defstruct [ 22 | :from, 23 | :path, 24 | :content, 25 | :hash, 26 | :owner, 27 | :filetype, 28 | :timestamp, 29 | history: [], 30 | issues: [], 31 | private: %{} 32 | ] 33 | 34 | @type opts :: keyword() 35 | 36 | @typedoc """ 37 | A `timestamp` as `integer` seconds since epoch. 38 | """ 39 | @type timestamp :: integer() 40 | 41 | @typedoc """ 42 | The `version` of a `%Source{}`. The version `1` indicates that the source has 43 | no changes. 44 | """ 45 | @type version :: pos_integer() 46 | 47 | @type kind :: :content | :path 48 | 49 | @type by :: module() 50 | @type owner :: module() 51 | 52 | @type key :: atom() 53 | @type value :: term() 54 | @type updater :: (term() -> term()) 55 | 56 | @type content :: String.t() 57 | @type extension :: String.t() 58 | 59 | @type from :: :file | :string 60 | 61 | @type issue :: term() 62 | 63 | @type filetype :: map() 64 | 65 | @typedoc """ 66 | The `struct` representing a source. 67 | 68 | ## Fields 69 | 70 | * `content` - of the `source`. 71 | 72 | * `filetype` - a `struct` implementing the behaviour `Rewrite.Filetype`. 73 | The `filetype` is nil when no additional implementation for the `filetype` 74 | is available. 75 | 76 | * `from` - contains `:file` or `:string` depending on whether the `source` 77 | is created from a file or a string. 78 | 79 | * `hash` - of the `source`. The `hash` is built from the `content` and 80 | `path`. 81 | 82 | * `history` - of the `source`. 83 | 84 | * `issues` - of the `source`. 85 | 86 | * `owner` - of the `source`. 87 | 88 | * `path` - of the `source`. Can be `nil` if the `source` was created by a 89 | `string`. 90 | 91 | * `private` - a field for user defined data. 92 | 93 | * `timestamp` - is set to the timestamp of the last modification of the file 94 | on disk at the time it was read. 95 | 96 | If the `source` was created by a `string`, the timestamp is the creation 97 | time. 98 | 99 | The timestamp will be updated when the `source` is updated. 100 | """ 101 | @type t :: %Source{ 102 | path: Path.t() | nil, 103 | content: String.t(), 104 | hash: String.t(), 105 | history: [{kind(), by(), String.t()}], 106 | issues: [{version(), issue()}], 107 | filetype: filetype(), 108 | timestamp: timestamp(), 109 | from: from(), 110 | owner: owner(), 111 | private: map() 112 | } 113 | 114 | @doc ~S''' 115 | Creates a new `%Source{}` from the given `path`. 116 | 117 | ## Examples 118 | 119 | iex> source = Source.read!("test/fixtures/source/hello.txt") 120 | iex> source.content 121 | """ 122 | hello 123 | """ 124 | ''' 125 | @spec read!(Path.t(), opts) :: t() 126 | def read!(path, opts \\ []) do 127 | content = File.read!(path) 128 | mtime = File.stat!(path, time: :posix).mtime 129 | owner = Keyword.get(opts, :owner, Rewrite) 130 | 131 | new( 132 | content: content, 133 | path: path, 134 | owner: owner, 135 | from: :file, 136 | timestamp: mtime 137 | ) 138 | end 139 | 140 | defp new(fields) do 141 | content = Keyword.fetch!(fields, :content) 142 | path = Keyword.get(fields, :path) 143 | 144 | struct!( 145 | Source, 146 | content: content, 147 | from: Keyword.fetch!(fields, :from), 148 | hash: hash(path, content), 149 | owner: Keyword.get(fields, :owner, Rewrite), 150 | path: path, 151 | timestamp: Keyword.fetch!(fields, :timestamp) 152 | ) 153 | end 154 | 155 | @doc """ 156 | Creates a new `%Source{}` from the given `string`. 157 | 158 | ## Options 159 | 160 | * `:owner` - an association to the module that owns the `source`. 161 | 162 | * `:dot_formatter` - a fromatter for the `source`. 163 | 164 | * `path` - the path of the `source`. 165 | 166 | ## Examples 167 | 168 | iex> source = Source.from_string("hello") 169 | iex> source.content 170 | "hello" 171 | iex> source.path 172 | nil 173 | iex> source.owner 174 | Rewrite 175 | 176 | iex> source = Source.from_string("hello", path: "hello.md", owner: MyApp) 177 | iex> source.path 178 | "hello.md" 179 | iex> source.owner 180 | MyApp 181 | 182 | """ 183 | @spec from_string(String.t(), opts()) :: t() 184 | def from_string(content, opts \\ []) 185 | 186 | def from_string(content, opts) when is_list(opts) do 187 | new( 188 | content: content, 189 | path: Keyword.get(opts, :path), 190 | owner: Keyword.get(opts, :owner, Rewrite), 191 | from: :string, 192 | timestamp: now(), 193 | dot_formatter: Keyword.get(opts, :dot_formatter) 194 | ) 195 | end 196 | 197 | # @deprecated "Use the from_string functions with `opts` instead." 198 | def from_string(content, path) when is_binary(path) do 199 | from_string(content, path: path) 200 | end 201 | 202 | @doc ~S""" 203 | Writes the source to disk. 204 | 205 | Returns `{:ok, source}` when the file was written successfully. The returned 206 | `source` does not include any previous changes or issues. 207 | 208 | If there's an error, this function returns `{:error, error}` where `error` 209 | is a `Rewrite.SourceError`. You can raise it manually with `raise/1`. 210 | 211 | Returns `{:error, error}` with `reason: :nopath` if the current `path` is nil. 212 | 213 | Returns `{:error, error}` with `reason: :changed` if the file was changed 214 | since reading. See also `file_changed?/1`. The option `force: true` forces 215 | overwriting a changed file. 216 | 217 | If the source `:path` was updated then the old file will be deleted. 218 | 219 | Missing directories are created. 220 | 221 | ## Options 222 | 223 | * `:force`, default: `false` - forces the saving to overwrite changed files. 224 | 225 | * `:rm`, default: `true` - prevents file deletion when set to `false`. 226 | 227 | ## Examples 228 | 229 | iex> ":test" |> Source.from_string() |> Source.write() 230 | {:error, %SourceError{reason: :nopath, path: nil, action: :write}} 231 | 232 | iex> path = "tmp/foo.txt" 233 | iex> File.write(path, "foo") 234 | iex> source = path |> Source.read!() |> Source.update(:content, "bar") 235 | iex> Source.updated?(source) 236 | true 237 | iex> {:ok, source} = Source.write(source) 238 | iex> File.read(path) 239 | {:ok, "bar\n"} 240 | iex> Source.updated?(source) 241 | false 242 | 243 | iex> source = Source.from_string("bar") 244 | iex> Source.write(source) 245 | {:error, %SourceError{reason: :nopath, path: nil, action: :write}} 246 | iex> source |> Source.update(:path, "tmp/bar.txt") |> Source.write() 247 | iex> File.read("tmp/bar.txt") 248 | {:ok, "bar\n"} 249 | 250 | iex> path = "tmp/ping.txt" 251 | iex> File.write(path, "ping") 252 | iex> source = Source.read!(path) 253 | iex> new_path = "tmp/pong.ex" 254 | iex> source = Source.update(source, :path, new_path) 255 | iex> Source.write(source) 256 | iex> File.exists?(path) 257 | false 258 | iex> File.read(new_path) 259 | {:ok, "ping\n"} 260 | 261 | iex> path = "tmp/ping.txt" 262 | iex> File.write(path, "ping") 263 | iex> source = Source.read!(path) 264 | iex> new_path = "tmp/pong.ex" 265 | iex> source = Source.update(source, :path, new_path) 266 | iex> Source.write(source, rm: false) 267 | iex> File.exists?(path) 268 | true 269 | 270 | iex> path = "tmp/ping.txt" 271 | iex> File.write(path, "ping") 272 | iex> source = path |> Source.read!() |> Source.update(:content, "peng") 273 | iex> File.write(path, "pong") 274 | iex> Source.write(source) 275 | {:error, %SourceError{reason: :changed, path: "tmp/ping.txt", action: :write}} 276 | iex> {:ok, _source} = Source.write(source, force: true) 277 | """ 278 | @spec write(t(), opts()) :: {:ok, t()} | {:error, SourceError.t()} 279 | def write(%Source{} = source, opts \\ []) do 280 | force = Keyword.get(opts, :force, false) 281 | rm = Keyword.get(opts, :rm, true) 282 | write(source, force, rm) 283 | end 284 | 285 | defp write(%Source{path: nil}, _force, _rm) do 286 | {:error, SourceError.exception(reason: :nopath, action: :write)} 287 | end 288 | 289 | defp write(%Source{history: []} = source, _force, _rm), do: {:ok, source} 290 | 291 | defp write(%Source{path: path, content: content} = source, force, rm) do 292 | if file_changed?(source) && !force do 293 | {:error, SourceError.exception(reason: :changed, path: source.path, action: :write)} 294 | else 295 | with :ok <- maybe_rm(source, rm), 296 | :ok <- mkdir_p(path), 297 | :ok <- file_write(path, eof_newline(content)) do 298 | {:ok, %{source | hash: hash(path, content), history: [], issues: []}} 299 | end 300 | end 301 | end 302 | 303 | defp file_write(path, content) do 304 | with {:error, reason} <- File.write(path, content) do 305 | {:error, SourceError.exception(reason: reason, path: path, action: :write)} 306 | end 307 | end 308 | 309 | defp mkdir_p(path) do 310 | path |> Path.dirname() |> File.mkdir_p() 311 | end 312 | 313 | defp maybe_rm(_source, false), do: :ok 314 | 315 | defp maybe_rm(source, true) do 316 | case {Source.updated?(source, :path), Source.get(source, :path, 1)} do 317 | {false, _path} -> 318 | :ok 319 | 320 | {true, nil} -> 321 | :ok 322 | 323 | {true, path} -> 324 | with {:error, reason} <- File.rm(path) do 325 | {:error, SourceError.exception(reason: reason, path: path, action: :write)} 326 | end 327 | end 328 | end 329 | 330 | @doc """ 331 | Same as `write/1`, but raises a `Rewrite.SourceError` exception in case of 332 | failure. 333 | """ 334 | @spec write!(t()) :: t() 335 | def write!(%Source{} = source) do 336 | case write(source) do 337 | {:ok, source} -> source 338 | {:error, error} -> raise error 339 | end 340 | end 341 | 342 | @doc """ 343 | Tries to delete the file `source`. 344 | 345 | Returns `:ok` if successful, or `{:error, reason}` if an error occurs. 346 | 347 | Note the file is deleted even if in read-only mode. 348 | """ 349 | @spec rm(t()) :: :ok | {:error, SourceError.t()} 350 | def rm(%Source{path: nil}), do: {:error, %SourceError{reason: :nopath, action: :rm}} 351 | 352 | def rm(%Source{path: path}) do 353 | with {:error, reason} <- File.rm(path) do 354 | {:error, %SourceError{reason: reason, action: :rm, path: path}} 355 | end 356 | end 357 | 358 | @doc """ 359 | Same as `rm/1`, but raises a `Rewrite.SourceError` exception in case of 360 | failure. Otherwise `:ok`. 361 | """ 362 | @spec rm!(t()) :: :ok 363 | def rm!(%Source{} = source) do 364 | with {:error, reason} <- rm(source), do: raise(reason) 365 | end 366 | 367 | @doc """ 368 | Returns the `version` of the given `source`. The value `1` indicates that the 369 | source has no changes. 370 | """ 371 | @spec version(t()) :: version() 372 | def version(%Source{history: history}), do: length(history) + 1 373 | 374 | @doc """ 375 | Returns the owner of the given `source`. 376 | """ 377 | @spec owner(t()) :: module() 378 | def owner(%Source{owner: owner}), do: owner 379 | 380 | @doc """ 381 | Adds the given `issues` to the `source`. 382 | """ 383 | @spec add_issues(t(), [issue()]) :: t() 384 | def add_issues(%Source{} = source, []), do: source 385 | 386 | def add_issues(%Source{issues: list} = source, issues) do 387 | version = version(source) 388 | issues = issues |> Enum.map(fn issue -> {version, issue} end) |> Enum.concat(list) 389 | 390 | %Source{source | issues: issues} 391 | end 392 | 393 | @doc """ 394 | Adds the given `issue` to the `source`. 395 | """ 396 | @spec add_issue(t(), issue()) :: t() 397 | def add_issue(%Source{} = source, issue), do: add_issues(source, [issue]) 398 | 399 | @doc """ 400 | Returns all issues of the given `source`. 401 | """ 402 | @spec issues(t()) :: [issue()] 403 | def issues(source) do 404 | source 405 | |> Map.get(:issues, []) 406 | |> Enum.map(fn {_version, issue} -> issue end) 407 | end 408 | 409 | @doc """ 410 | Assigns a private `key` and `value` to the `source`. 411 | 412 | This is not used or accessed by Rewrite, but is intended as private storage 413 | for users or libraries that wish to store additional data about a source. 414 | 415 | ## Examples 416 | 417 | iex> source = 418 | ...> "a + b" 419 | ...> |> Source.from_string() 420 | ...> |> Source.put_private(:origin, :example) 421 | iex> source.private[:origin] 422 | :example 423 | """ 424 | @spec put_private(t(), key(), value()) :: t() 425 | def put_private(%Source{} = source, key, value) do 426 | Map.update!(source, :private, &Map.put(&1, key, value)) 427 | end 428 | 429 | @doc ~S""" 430 | Updates the `content` or the `path` of a `source`. 431 | 432 | The given `value` can be of type `t:value/0` or an updater function that gets 433 | the current value and returns the new value. 434 | 435 | ## Examples 436 | 437 | iex> source = 438 | ...> "foo" 439 | ...> |> Source.from_string() 440 | ...> |> Source.update(:path, "test/fixtures/new.exs", by: Example) 441 | ...> |> Source.update(:content, "bar") 442 | iex> source.history 443 | [{:content, Rewrite, "foo"}, {:path, Example, nil}] 444 | iex> source.content 445 | "bar" 446 | 447 | iex> source = 448 | ...> "foo" 449 | ...> |> Source.from_string() 450 | ...> |> Source.update(:content, fn content -> content <> "bar" end) 451 | iex> source.content 452 | "foobar" 453 | 454 | With a `Rewrite.Source.Ex`. Note that the AST is generated by `Sourceror`. 455 | 456 | iex> source = 457 | ...> ":a" 458 | ...> |> Source.Ex.from_string() 459 | ...> |> Source.update(:quoted, fn quoted -> 460 | ...> {:__block__, meta, [atom]} = quoted 461 | ...> {:__block__, meta, [{:ok, atom}]} 462 | ...> end) 463 | iex> source.content 464 | "{:ok, :a}\n" 465 | 466 | If the new value is equal to the current value, no history will be added. 467 | 468 | iex> source = 469 | ...> "42" 470 | ...> |> Source.from_string() 471 | ...> |> Source.update(:content, "21", by: Example) 472 | ...> |> Source.update(:content, "21", by: Example) 473 | iex> source.history 474 | [{:content, Example, "42"}] 475 | """ 476 | @spec update(Source.t(), key(), value() | updater(), opts()) :: Source.t() 477 | def update(source, key, value, opts \\ []) 478 | 479 | def update(%Source{} = source, key, value, opts) 480 | when key in [:content, :path] and is_list(opts) do 481 | legacy = Map.fetch!(source, key) 482 | value = value(value, legacy) 483 | 484 | case legacy == value do 485 | true -> 486 | source 487 | 488 | false -> 489 | by = Keyword.get(opts, :by, Rewrite) 490 | 491 | source 492 | |> update_timestamp() 493 | |> do_update(key, value) 494 | |> update_history(key, by, legacy) 495 | |> update_filetype(key, opts) 496 | end 497 | end 498 | 499 | def update(%Source{filetype: %module{}} = source, key, value, opts) 500 | when is_atom(key) and is_list(opts) do 501 | updates = module.handle_update(source, key, value, opts) 502 | 503 | case updates do 504 | [] -> 505 | source 506 | 507 | updates -> 508 | filetype = Keyword.get(updates, :filetype, source.filetype) 509 | content = Keyword.get(updates, :content, source.content) 510 | by = Keyword.get(opts, :by, Rewrite) 511 | 512 | source 513 | |> Map.put(:filetype, filetype) 514 | |> update_content(content, by) 515 | |> update_timestamp() 516 | end 517 | end 518 | 519 | # @deprecated "Use the update functions with `opts` instead." 520 | def update(source, by, key, content) do 521 | update(source, key, content, by: by) 522 | end 523 | 524 | defp value(updater, legacy) when is_function(updater, 1), do: updater.(legacy) 525 | defp value(value, _legacy), do: value 526 | 527 | defp update_timestamp(source), do: %{source | timestamp: now()} 528 | 529 | defp do_update(source, :path, path) do 530 | %Source{source | path: path} 531 | end 532 | 533 | defp do_update(source, :content, content) do 534 | %Source{source | content: content} 535 | end 536 | 537 | defp update_filetype(%{filetype: nil} = source, _key, _opts), do: source 538 | 539 | defp update_filetype(%{filetype: %module{}} = source, key, opts) when is_atom(key) do 540 | filetype = module.handle_update(source, key, opts) 541 | 542 | %Source{source | filetype: filetype} 543 | end 544 | 545 | defp update_content(source, nil, _by), do: source 546 | 547 | defp update_content(source, content, by) do 548 | legacy = Map.fetch!(source, :content) 549 | 550 | case legacy == content do 551 | true -> 552 | source 553 | 554 | false -> 555 | source 556 | |> do_update(:content, content) 557 | |> update_history(:content, by, legacy) 558 | end 559 | end 560 | 561 | @doc """ 562 | Sets the `timestamp` to the current POSIX timestamp. 563 | 564 | Does not touch the underlying file. 565 | """ 566 | @spec touch(t()) :: t() 567 | def touch(source), do: touch(source, now()) 568 | 569 | @doc """ 570 | Sets the `timestamp` of the given `source` to the given `timestamp`. 571 | 572 | Does not touch the underlying file. 573 | """ 574 | @spec touch(t(), timestamp()) :: t() 575 | def touch(source, timestamp), do: %{source | timestamp: timestamp} 576 | 577 | @doc """ 578 | Returns `true` if the source was updated. 579 | 580 | The optional argument `kind` specifies whether only `:code` changes or `:path` 581 | changes are considered. Defaults to `:any`. 582 | 583 | ## Examples 584 | 585 | iex> source = Source.from_string("foo") 586 | iex> Source.updated?(source) 587 | false 588 | iex> source = Source.update(source, :content, "bar") 589 | iex> Source.updated?(source) 590 | true 591 | iex> Source.updated?(source, :path) 592 | false 593 | iex> Source.updated?(source, :content) 594 | true 595 | """ 596 | @spec updated?(t(), kind :: :content | :path | :any) :: boolean() 597 | def updated?(source, kind \\ :any) 598 | 599 | def updated?(%Source{history: []}, _kind), do: false 600 | 601 | def updated?(%Source{history: _history}, :any), do: true 602 | 603 | def updated?(%Source{history: history}, kind) when kind in [:content, :path] do 604 | Enum.any?(history, fn 605 | {^kind, _by, _value} -> true 606 | _update -> false 607 | end) 608 | end 609 | 610 | @doc """ 611 | Returns `true` if the file has been modified since it was read. 612 | 613 | If the key `:from` does not contain `:file` the function returns `false`. 614 | 615 | ## Examples 616 | 617 | iex> File.write("tmp/hello.txt", "hello") 618 | iex> source = Source.read!("tmp/hello.txt") 619 | iex> Source.file_changed?(source) 620 | false 621 | iex> File.write("tmp/hello.txt", "Hello, world!") 622 | iex> Source.file_changed?(source) 623 | true 624 | iex> source = Source.update(source, :path, nil) 625 | iex> Source.file_changed?(source) 626 | true 627 | iex> File.write("tmp/hello.txt", "hello") 628 | iex> Source.file_changed?(source) 629 | false 630 | iex> File.rm!("tmp/hello.txt") 631 | iex> Source.file_changed?(source) 632 | true 633 | 634 | iex> source = Source.from_string("hello") 635 | iex> Source.file_changed?(source) 636 | false 637 | """ 638 | @spec file_changed?(Source.t()) :: boolean 639 | def file_changed?(%Source{from: from}) when from != :file, do: false 640 | 641 | def file_changed?(%Source{} = source) do 642 | path = get(source, :path, 1) 643 | 644 | case File.read(path) do 645 | {:ok, content} -> hash(path, content) != source.hash 646 | _error -> true 647 | end 648 | end 649 | 650 | @doc """ 651 | Returns `true` if the `source` has issues for the given `version`. 652 | 653 | The `version` argument also accepts `:actual` and `:all` to check whether the 654 | `source` has problems for the actual version or if there are problems at all. 655 | 656 | ## Examples 657 | 658 | iex> source = 659 | ...> "a + b" 660 | ...> |> Source.Ex.from_string(path: "some/where/plus.exs") 661 | ...> |> Source.add_issue(%{issue: :foo}) 662 | ...> |> Source.update(:path, "some/where/else/plus.exs") 663 | ...> |> Source.add_issue(%{issue: :bar}) 664 | iex> Source.has_issues?(source) 665 | true 666 | iex> Source.has_issues?(source, 1) 667 | true 668 | iex> Source.has_issues?(source, :all) 669 | true 670 | iex> source = Source.update(source, :content, "a - b") 671 | iex> Source.has_issues?(source) 672 | false 673 | iex> Source.has_issues?(source, 2) 674 | true 675 | iex> Source.has_issues?(source, :all) 676 | true 677 | """ 678 | @spec has_issues?(t(), version() | :actual | :all) :: boolean 679 | def has_issues?(source, version \\ :actual) 680 | 681 | def has_issues?(%Source{issues: issues}, :all), do: not_empty?(issues) 682 | 683 | def has_issues?(%Source{} = source, :actual) do 684 | has_issues?(source, version(source)) 685 | end 686 | 687 | def has_issues?(%Source{issues: issues, history: history}, version) 688 | when version >= 1 and version <= length(history) + 1 do 689 | issues 690 | |> Enum.filter(fn {for_version, _issue} -> for_version == version end) 691 | |> not_empty?() 692 | end 693 | 694 | @doc """ 695 | Gets the value for `:content`, `:path` in `source` or a specific `key` in 696 | `filetype`. 697 | 698 | Raises `Rewrite.SourceKeyError` if the `key` can't be found. 699 | """ 700 | @spec get(Source.t(), key()) :: value() 701 | def get(%Source{path: path}, :path), do: path 702 | 703 | def get(%Source{content: content}, :content), do: content 704 | 705 | def get(%Source{filetype: nil}, key) when is_atom(key) do 706 | raise SourceKeyError, key: key 707 | end 708 | 709 | def get(%Source{filetype: %module{}} = source, key) do 710 | case module.fetch(source, key) do 711 | {:ok, value} -> value 712 | :error -> raise SourceKeyError, key: key 713 | end 714 | end 715 | 716 | @doc """ 717 | Gets the value for `:content`, `:path` in `source` or a specific `key` in 718 | `filetype` for the given `version`. 719 | 720 | Raises `Rewrite.SourceKeyError` if the `key` can't be found. 721 | 722 | ## Examples 723 | 724 | iex> bar = 725 | ...> \""" 726 | ...> defmodule Bar do 727 | ...> def bar, do: :bar 728 | ...> end 729 | ...> \""" 730 | iex> foo = 731 | ...> \""" 732 | ...> defmodule Foo do 733 | ...> def foo, do: :foo 734 | ...> end 735 | ...> \""" 736 | iex> source = Source.Ex.from_string(bar) 737 | iex> source = Source.update(source, :content, foo) 738 | iex> Source.get(source, :content) == foo 739 | true 740 | iex> Source.get(source, :content, 2) == foo 741 | true 742 | iex> Source.get(source, :content, 1) == bar 743 | true 744 | 745 | iex> source = 746 | ...> "hello" 747 | ...> |> Source.from_string(path: "some/where/hello.txt") 748 | ...> |> Source.update(:path, "some/where/else/hello.txt") 749 | ...> Source.get(source, :path, 1) 750 | "some/where/hello.txt" 751 | iex> Source.get(source, :path, 2) 752 | "some/where/else/hello.txt" 753 | 754 | """ 755 | @spec get(Source.t(), key(), version()) :: value() 756 | def get(%Source{history: history} = source, key, version) 757 | when key in [:content, :path] and 758 | version >= 1 and version <= length(history) + 1 do 759 | value = Map.fetch!(source, key) 760 | 761 | history 762 | |> Enum.take(length(history) - version + 1) 763 | |> Enum.reduce(value, fn 764 | {^key, _by, value}, _value -> value 765 | _version, value -> value 766 | end) 767 | end 768 | 769 | def get(%Source{filetype: nil}, key, _version) do 770 | raise SourceKeyError, key: key 771 | end 772 | 773 | def get(%Source{filetype: %moudle{}} = source, key, version) do 774 | case moudle.fetch(source, key, version) do 775 | {:ok, value} -> value 776 | :error -> raise SourceKeyError, key: key 777 | end 778 | end 779 | 780 | @doc ~S''' 781 | Returns iodata showing all diffs of the given `source`. 782 | 783 | See `TextDiff.format/3` for options. 784 | 785 | ## Examples 786 | 787 | iex> code = """ 788 | ...> def foo( x ) do 789 | ...> {:x, 790 | ...> x} 791 | ...> end 792 | ...> """ 793 | iex> formatted = code |> Code.format_string!() |> IO.iodata_to_binary() 794 | iex> source = Source.Ex.from_string(code) 795 | iex> source |> Source.diff() |> IO.iodata_to_binary() 796 | "" 797 | iex> source 798 | ...> |> Source.update(:content, formatted) 799 | ...> |> Source.diff(color: false) 800 | ...> |> IO.iodata_to_binary() 801 | """ 802 | 1 - |def foo( x ) do 803 | 2 - | {:x, 804 | 3 - | x} 805 | 1 + |def foo(x) do 806 | 2 + | {:x, x} 807 | 4 3 |end 808 | 5 4 | 809 | """ 810 | ''' 811 | @spec diff(t(), opts()) :: iodata() 812 | def diff(%Source{} = source, opts \\ []) do 813 | TextDiff.format( 814 | source |> get(:content, 1) |> eof_newline(), 815 | source |> get(:content) |> eof_newline(), 816 | opts 817 | ) 818 | end 819 | 820 | @doc """ 821 | Calculates the current hash from the given `source`. 822 | """ 823 | @spec hash(t()) :: non_neg_integer() 824 | def hash(%Source{path: path, content: content}), do: hash(path, content) 825 | 826 | defp hash(path, code), do: :erlang.phash2({path, code}) 827 | 828 | @doc """ 829 | Sets the `filetype` for the `source`. 830 | """ 831 | @spec filetype(t(), filetype()) :: t() 832 | def filetype(%Source{} = source, filetype), do: %Source{source | filetype: filetype} 833 | 834 | @doc """ 835 | Returns true when `from` matches to value for key `:from`. 836 | 837 | ## Examples 838 | 839 | iex> source = Source.from_string("hello") 840 | iex> Source.from?(source, :file) 841 | false 842 | iex> Source.from?(source, :string) 843 | true 844 | """ 845 | @spec from?(t(), :file | :string) :: boolean 846 | def from?(%Source{from: value}, from) when from in [:file, :string], do: value == from 847 | 848 | @doc """ 849 | Undoes the given `number` of changes. 850 | 851 | ## Examples 852 | iex> a = Source.from_string("test-a", path: "test/foo.txt") 853 | iex> b = Source.update(a, :content, "test-b") 854 | iex> c = Source.update(b, :path, "test/bar.txt") 855 | iex> d = Source.update(c, :content, "test-d") 856 | iex> d |> Source.undo() |> Source.get(:content) 857 | "test-b" 858 | iex> d |> Source.undo(1) |> Source.get(:content) 859 | "test-b" 860 | iex> d |> Source.undo(2) |> Source.get(:path) 861 | "test/foo.txt" 862 | iex> d |> Source.undo(3) |> Source.get(:content) 863 | "test-a" 864 | iex> d |> Source.undo(9) |> Source.get(:content) 865 | "test-a" 866 | iex> d |> Source.undo(9) |> Source.updated?() 867 | false 868 | iex> d |> Source.undo(-9) |> Source.get(:content) 869 | "test-d" 870 | """ 871 | @spec undo(t(), non_neg_integer()) :: t() 872 | def undo(source, number \\ 1) 873 | 874 | def undo(%Source{filetype: nil} = source, number) when number < 1, do: source 875 | 876 | def undo(%Source{filetype: %module{}} = source, 0), do: module.undo(source) 877 | 878 | def undo(%Source{history: []} = source, _number), do: undo(source, 0) 879 | 880 | def undo(%Source{history: [undo | history]} = source, number) do 881 | source = 882 | case undo do 883 | {:content, _by, content} -> %Source{source | history: history, content: content} 884 | {:path, _by, path} -> %Source{source | history: history, path: path} 885 | end 886 | 887 | undo(source, number - 1) 888 | end 889 | 890 | @doc ~s''' 891 | Formats the given `source`. 892 | 893 | If the `source` was formatted the `source` gets a new `:history` entry, 894 | otherwise the unchanged `source` is returned. 895 | 896 | ## Options 897 | 898 | * `by` - an `atom` or `module` that is used as `:by` key when the `source` 899 | is updated. Defaults to `Rewrite`. 900 | 901 | * `dot_formatter` - defaults to `Rewrite.DotFormatter.default/0`. 902 | 903 | * Accepts also the same options as `Code.format_string!/2`. 904 | 905 | ## Examples 906 | 907 | 908 | iex> source = Source.Ex.from_string(""" 909 | ...> defmodule Foo do 910 | ...> def foo(x), do: bar x 911 | ...> end 912 | ...> """) 913 | iex> {:ok, formatted} = Source.format(source, force_do_end_blocks: true) 914 | iex> formatted.content 915 | """ 916 | defmodule Foo do 917 | def foo(x) do 918 | bar(x) 919 | end 920 | end 921 | """ 922 | iex> dot_formatter = DotFormatter.from_formatter_opts(locals_without_parens: [bar: 1]) 923 | iex> {:ok, formatted} = Source.format(source, 924 | ...> dot_formatter: dot_formatter, force_do_end_blocks: true 925 | ...> ) 926 | iex> formatted.content 927 | """ 928 | defmodule Foo do 929 | def foo(x) do 930 | bar x 931 | end 932 | end 933 | """ 934 | ''' 935 | @spec format(t(), opts()) :: {:ok, t()} | {:errror, term()} 936 | def format(%Source{} = source, opts \\ []) do 937 | path = Map.get(source, :path) || default_path(source) 938 | dot_fromatter = Keyword.get(opts, :dot_formatter, DotFormatter.default()) 939 | by = Keyword.get(opts, :by, Rewrite) 940 | 941 | with {:ok, formatted} <- DotFormatter.format_string(dot_fromatter, path, source.content, opts) do 942 | {:ok, update(source, :content, formatted, by: by)} 943 | end 944 | end 945 | 946 | @doc """ 947 | Same as `format/2`, but raises an exception in case of failure. 948 | """ 949 | @spec format(t(), opts()) :: t() 950 | def format!(%Source{} = source, opts \\ []) do 951 | case format(source, opts) do 952 | {:ok, source} -> source 953 | {:error, error} -> raise error 954 | end 955 | end 956 | 957 | @doc """ 958 | The default `path` for the `source`. 959 | """ 960 | @spec default_path(t()) :: Path.t() 961 | def default_path(%Source{filetype: %module{}}), do: module.default_path() 962 | def default_path(_source), do: "nofile" 963 | 964 | defp update_history(%Source{history: history} = source, key, by, legacy) do 965 | %{source | history: [{key, by, legacy} | history]} 966 | end 967 | 968 | defp not_empty?(enum), do: not Enum.empty?(enum) 969 | 970 | defp eof_newline(string), do: String.trim_trailing(string) <> "\n" 971 | 972 | defp now, do: :os.system_time(:second) 973 | 974 | defimpl Inspect do 975 | def inspect(source, _opts) do 976 | "#Rewrite.Source<#{source.path}>" 977 | end 978 | end 979 | end 980 | -------------------------------------------------------------------------------- /lib/rewrite/source/ex.ex: -------------------------------------------------------------------------------- 1 | defmodule Rewrite.Source.Ex do 2 | @moduledoc ~s''' 3 | An implementation of `Rewrite.Filetype` to handle Elixir source files. 4 | 5 | The module uses the [`sourceror`](https://github.com/doorgan/sourceror) package 6 | to provide an [extended AST](https://hexdocs.pm/sourceror/readme.html#sourceror-s-ast) 7 | representation of an Elixir file. 8 | 9 | `Ex` extends the `source` by the key `:quoted`. 10 | 11 | ## Updating and resyncing `:quoted` 12 | 13 | When `:quoted` becomes updated, content becomes formatted to the Elixir source 14 | code. To keep the code in `:content` in sync with the AST in `:quoted`, the 15 | new code is parsed to a new `:quoted`. That means that 16 | `Source.update(source, :quoted, quoted)` also updates the AST. 17 | 18 | The resyncing of `:quoted` can be suppressed with the option 19 | `resync_quoted: false`. 20 | 21 | ## Examples 22 | 23 | iex> source = Source.Ex.from_string("Enum.reverse(list)") 24 | iex> Source.get(source, :quoted) 25 | {{:., [trailing_comments: [], line: 1, column: 5], 26 | [ 27 | {:__aliases__, 28 | [ 29 | trailing_comments: [], 30 | leading_comments: [], 31 | last: [line: 1, column: 1], 32 | line: 1, 33 | column: 1 34 | ], [:Enum]}, 35 | :reverse 36 | ]}, 37 | [ 38 | trailing_comments: [], 39 | leading_comments: [], 40 | closing: [line: 1, column: 18], 41 | line: 1, 42 | column: 6 43 | ], [{:list, [trailing_comments: [], leading_comments: [], line: 1, column: 14], nil}]} 44 | iex> quoted = Code.string_to_quoted!(""" 45 | ...> defmodule MyApp.New do 46 | ...> def foo do 47 | ...> :foo 48 | ...> end 49 | ...> end 50 | ...> """) 51 | iex> source = Source.update(source, :quoted, quoted) 52 | iex> Source.updated?(source) 53 | true 54 | iex> Source.get(source, :content) 55 | """ 56 | defmodule MyApp.New do 57 | def foo do 58 | :foo 59 | end 60 | end 61 | """ 62 | iex> Source.get(source, :quoted) == quoted 63 | false 64 | 65 | Without resyncing `:quoted`: 66 | 67 | iex> project = Rewrite.new(filetypes: [{Source.Ex, resync_quoted: false}]) 68 | iex> path = "test/fixtures/source/simple.ex" 69 | iex> project = Rewrite.read!(project, path) 70 | iex> source = Rewrite.source!(project, path) 71 | iex> quoted = Code.string_to_quoted!(""" 72 | ...> defmodule MyApp.New do 73 | ...> def foo do 74 | ...> :foo 75 | ...> end 76 | ...> end 77 | ...> """) 78 | iex> source = Source.update(source, :quoted, quoted) 79 | iex> Source.get(source, :quoted) == quoted 80 | true 81 | ''' 82 | 83 | alias Rewrite.DotFormatter 84 | alias Rewrite.Source 85 | alias Rewrite.Source.Ex 86 | alias Sourceror.Zipper 87 | 88 | @enforce_keys [:quoted] 89 | defstruct [:quoted, opts: []] 90 | 91 | @type t :: %Ex{ 92 | quoted: Macro.t(), 93 | opts: keyword() 94 | } 95 | 96 | @extensions [".ex", ".exs"] 97 | @default_path "nofile.ex" 98 | 99 | @behaviour Rewrite.Filetype 100 | 101 | @impl Rewrite.Filetype 102 | def extensions, do: @extensions 103 | 104 | @impl Rewrite.Filetype 105 | def default_path, do: @default_path 106 | 107 | @doc """ 108 | Returns a `%Rewrite.Source{}` with an added `:filetype`. 109 | """ 110 | @impl Rewrite.Filetype 111 | def from_string(string, opts \\ []) 112 | 113 | def from_string(string, opts) when is_list(opts) do 114 | string 115 | |> Source.from_string(opts) 116 | |> add_filetype(opts) 117 | end 118 | 119 | # @deprecated: use from_string/2 with opts as second argument 120 | def from_string(string, path) when is_binary(path) do 121 | from_string(string, path: path) 122 | end 123 | 124 | @doc """ 125 | Returns a `%Rewrite.Source{}` with an added `:filetype`. 126 | 127 | The `content` reads from the file under the given `path`. 128 | 129 | ## Options 130 | 131 | * `:resync_quoted`, default: `true` - forcing the re-parsing when the source 132 | field `quoted` is updated. 133 | """ 134 | @impl Rewrite.Filetype 135 | def read!(path, opts \\ []) do 136 | path 137 | |> Source.read!() 138 | |> add_filetype(opts) 139 | end 140 | 141 | @impl Rewrite.Filetype 142 | def handle_update(%Source{filetype: %Ex{} = ex}, :path, _opts), do: ex 143 | 144 | def handle_update(%Source{filetype: %Ex{} = ex} = source, :content, _opts) do 145 | %Ex{ex | quoted: Sourceror.parse_string!(source.content)} 146 | end 147 | 148 | @impl Rewrite.Filetype 149 | def handle_update(%Source{} = source, :quoted, value, opts) do 150 | %Source{filetype: %Ex{} = ex} = source 151 | 152 | quoted = quoted(value, ex.quoted) 153 | 154 | if ex.quoted == quoted do 155 | [] 156 | else 157 | {quoted, code} = update_quoted(source, quoted, opts) 158 | 159 | [content: code, filetype: %Ex{ex | quoted: quoted}] 160 | end 161 | end 162 | 163 | defp quoted(updater, current) when is_function(updater, 1), do: updater.(current) 164 | defp quoted(quoted, _current), do: quoted 165 | 166 | defp update_quoted(%Source{filetype: %Ex{} = ex} = source, quoted, opts) do 167 | file = if source.path, do: source.path, else: "nofile.ex" 168 | dot_formatter = Keyword.get(opts, :dot_formatter, DotFormatter.default()) 169 | code = DotFormatter.format_quoted!(dot_formatter, file, quoted) 170 | 171 | quoted = 172 | case resync_quoted?(ex) do 173 | true -> Sourceror.parse_string!(code) 174 | false -> quoted 175 | end 176 | 177 | {quoted, code} 178 | end 179 | 180 | @impl Rewrite.Filetype 181 | def undo(%Source{filetype: %Ex{} = ex} = source) do 182 | Source.filetype(source, %Ex{ex | quoted: Sourceror.parse_string!(source.content)}) 183 | end 184 | 185 | @impl Rewrite.Filetype 186 | def fetch(%Source{filetype: %Ex{} = ex}, :quoted) do 187 | {:ok, ex.quoted} 188 | end 189 | 190 | def fetch(%Source{}, _key), do: :error 191 | 192 | @impl Rewrite.Filetype 193 | def fetch(%Source{filetype: %Ex{}, history: history} = source, :quoted, version) 194 | when version >= 1 and version <= length(history) + 1 do 195 | value = source |> Source.get(:content, version) |> Sourceror.parse_string!() 196 | 197 | {:ok, value} 198 | end 199 | 200 | def fetch(%Source{filetype: %Ex{}}, _key, _version), do: :error 201 | 202 | @doc """ 203 | Returns the current modules for the given `source`. 204 | """ 205 | @spec modules(Source.t()) :: [module()] 206 | def modules(%Source{filetype: %Ex{} = ex}) do 207 | get_modules(ex.quoted) 208 | end 209 | 210 | @doc ~S''' 211 | Returns the modules of a `source` for the given `version`. 212 | 213 | ## Examples 214 | 215 | iex> bar = 216 | ...> """ 217 | ...> defmodule Bar do 218 | ...> def bar, do: :bar 219 | ...> end 220 | ...> """ 221 | iex> foo = 222 | ...> """ 223 | ...> defmodule Baz.Foo do 224 | ...> def foo, do: :foo 225 | ...> end 226 | ...> """ 227 | iex> source = Source.Ex.from_string(bar) 228 | iex> source = Source.update(source, :content, bar <> foo) 229 | iex> Source.Ex.modules(source) 230 | [Baz.Foo, Bar] 231 | iex> Source.Ex.modules(source, 2) 232 | [Baz.Foo, Bar] 233 | iex> Source.Ex.modules(source, 1) 234 | [Bar] 235 | ''' 236 | @spec modules(Source.t(), Source.version()) :: [module()] 237 | def modules(%Source{filetype: %Ex{}, history: history} = source, version) 238 | when version >= 1 and version <= length(history) + 1 do 239 | source |> Source.get(:content, version) |> Sourceror.parse_string!() |> get_modules() 240 | end 241 | 242 | defp add_filetype(source, opts) do 243 | opts = if opts, do: Keyword.take(opts, [:formatter_opts, :resync_quoted]) 244 | 245 | ex = 246 | struct!(Ex, 247 | quoted: Sourceror.parse_string!(source.content), 248 | opts: opts 249 | ) 250 | 251 | Source.filetype(source, ex) 252 | end 253 | 254 | defp get_modules(code) do 255 | code 256 | |> Zipper.zip() 257 | |> Zipper.traverse([], fn 258 | %Zipper{node: {:defmodule, _meta, [module | _args]}} = zipper, acc -> 259 | {zipper, [concat(module) | acc]} 260 | 261 | zipper, acc -> 262 | {zipper, acc} 263 | end) 264 | |> elem(1) 265 | |> Enum.uniq() 266 | |> Enum.filter(&is_atom/1) 267 | end 268 | 269 | defp concat({:__aliases__, _meta, module}), do: Module.concat(module) 270 | 271 | defp resync_quoted?(%Ex{opts: opts}), do: Keyword.get(opts, :resync_quoted, true) 272 | 273 | defimpl Inspect do 274 | def inspect(_source, _opts) do 275 | "#Rewrite.Source.Ex<.ex,.exs>" 276 | end 277 | end 278 | 279 | @deprecated "Use the fromatting functionlity provided by Rewrite.DotFormatter instead." 280 | def put_formatter_opts(source, _opts), do: source 281 | 282 | @deprecated "Use the fromatting functionlity provided by Rewrite.DotFormatter instead." 283 | def format(source, _formatter_opts \\ nil) do 284 | dot_formatter = 285 | case DotFormatter.read() do 286 | {:ok, dot_formatter} -> dot_formatter 287 | {:error, _error} -> DotFormatter.default() 288 | end 289 | 290 | source 291 | |> Source.format!(dot_formatter: dot_formatter) 292 | |> Source.get(:content) 293 | end 294 | end 295 | -------------------------------------------------------------------------------- /lib/rewrite/source_error.ex: -------------------------------------------------------------------------------- 1 | defmodule Rewrite.SourceError do 2 | @moduledoc """ 3 | An exception for when a function can not handle a source. 4 | """ 5 | 6 | alias Rewrite.SourceError 7 | 8 | @type reason :: :nopath | :changed | File.posix() 9 | @type action :: :rm 10 | @type path :: nil | Path.t() 11 | 12 | @type t :: %SourceError{reason: reason, action: action, path: path} 13 | 14 | @enforce_keys [:reason, :action] 15 | defexception [:reason, :path, :action] 16 | 17 | @impl true 18 | def exception(value) do 19 | struct!(SourceError, value) 20 | end 21 | 22 | @impl true 23 | def message(%SourceError{reason: :nopath, action: action}) do 24 | "could not #{format(action)}: no path found" 25 | end 26 | 27 | def message(%SourceError{reason: :changed, action: action, path: path}) do 28 | "could not #{format(action)} #{inspect(path)}: file changed since reading" 29 | end 30 | 31 | def message(%SourceError{reason: posix, action: action, path: path}) do 32 | """ 33 | could not #{format(action)} #{inspect(path)}\ 34 | : #{IO.iodata_to_binary(:file.format_error(posix))}\ 35 | """ 36 | end 37 | 38 | def format(action) do 39 | case action do 40 | :rm -> "remove file" 41 | :write -> "write to file" 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/rewrite/source_key_error.ex: -------------------------------------------------------------------------------- 1 | defmodule Rewrite.SourceKeyError do 2 | @moduledoc """ 3 | An exception for when a key can't be found in a source. 4 | """ 5 | 6 | alias Rewrite.SourceKeyError 7 | 8 | @enforce_keys [:key] 9 | defexception [:key] 10 | 11 | @impl true 12 | def exception(value) do 13 | struct!(SourceKeyError, value) 14 | end 15 | 16 | @impl true 17 | def message(%SourceKeyError{key: key}) do 18 | """ 19 | key #{inspect(key)} not found in source. This function is just definded for \ 20 | the keys :content, :path and keys provided by filetype.\ 21 | """ 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/rewrite/update_error.ex: -------------------------------------------------------------------------------- 1 | defmodule Rewrite.UpdateError do 2 | @moduledoc """ 3 | An exception for when a function can not handle a source. 4 | """ 5 | 6 | alias Rewrite.UpdateError 7 | 8 | @type reason :: :nopath | :overwrites | :filetype 9 | 10 | @type t :: %UpdateError{ 11 | reason: reason, 12 | source: Path.t(), 13 | path: Path.t() | nil 14 | } 15 | 16 | @enforce_keys [:reason] 17 | defexception [:reason, :source, :path] 18 | 19 | @impl true 20 | def exception(value) do 21 | struct!(UpdateError, value) 22 | end 23 | 24 | @impl true 25 | def message(%UpdateError{reason: :nopath, source: source}) do 26 | "#{format(source)}: no path in updated source" 27 | end 28 | 29 | def message(%UpdateError{reason: :overwrites, source: source, path: path}) do 30 | "#{format(source)}: updated source overwrites #{inspect(path)}" 31 | end 32 | 33 | defp format(source), do: "can't update source #{inspect(source)}" 34 | end 35 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Rewrite.MixProject do 2 | use Mix.Project 3 | 4 | @version "1.1.2" 5 | @source_url "https://github.com/hrzndhrn/rewrite" 6 | 7 | def project do 8 | [ 9 | aliases: aliases(), 10 | app: :rewrite, 11 | version: @version, 12 | deps: deps(), 13 | description: description(), 14 | dialyzer: dialyzer(), 15 | docs: docs(), 16 | elixir: "~> 1.13", 17 | package: package(), 18 | preferred_cli_env: preferred_cli_env(), 19 | source_url: @source_url, 20 | start_permanent: Mix.env() == :prod, 21 | test_coverage: [tool: ExCoveralls], 22 | xref: [exclude: [FreedomFormatter.Formatter]] 23 | ] 24 | end 25 | 26 | def application do 27 | [ 28 | extra_applications: [:logger], 29 | mod: {Rewrite.Application, []} 30 | ] 31 | end 32 | 33 | defp description do 34 | "An API for rewriting sources in an Elixir project. Powered by sourceror." 35 | end 36 | 37 | defp docs do 38 | [ 39 | main: Rewrite, 40 | source_ref: "v#{@version}", 41 | formatters: ["html"], 42 | api_reference: false, 43 | groups_for_modules: [ 44 | Hooks: [ 45 | Rewrite.Hook, 46 | Rewrite.Hook.DotFormatterUpdater 47 | ] 48 | ] 49 | ] 50 | end 51 | 52 | defp dialyzer do 53 | [ 54 | ignore_warnings: ".dialyzer_ignore.exs", 55 | plt_add_apps: [:mix], 56 | plt_file: {:no_warn, "test/support/plts/dialyzer.plt"}, 57 | flags: [:unmatched_returns] 58 | ] 59 | end 60 | 61 | def preferred_cli_env do 62 | [ 63 | carp: :test, 64 | cover: :test, 65 | coveralls: :test, 66 | "coveralls.detail": :test, 67 | "coveralls.html": :test, 68 | "coveralls.github": :test 69 | ] 70 | end 71 | 72 | defp aliases do 73 | [ 74 | carp: "test --trace --seed 0 --max-failures 1", 75 | cover: "coveralls.html" 76 | ] 77 | end 78 | 79 | defp deps do 80 | [ 81 | {:glob_ex, "~> 0.1"}, 82 | {:sourceror, "~> 1.0"}, 83 | {:text_diff, "~> 0.1"}, 84 | # dev/test 85 | {:benchee_dsl, "~> 0.5", only: :dev}, 86 | {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, 87 | {:dialyxir, "~> 1.0", only: [:dev], runtime: false}, 88 | {:ex_doc, "~> 0.25", only: :dev, runtime: false}, 89 | {:excoveralls, "~> 0.10", only: :test} 90 | ] 91 | end 92 | 93 | defp package do 94 | [ 95 | maintainers: ["Marcus Kruse"], 96 | licenses: ["MIT"], 97 | links: %{"GitHub" => @source_url} 98 | ] 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "benchee": {:hex, :benchee, "1.3.1", "c786e6a76321121a44229dde3988fc772bca73ea75170a73fd5f4ddf1af95ccf", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "76224c58ea1d0391c8309a8ecbfe27d71062878f59bd41a390266bf4ac1cc56d"}, 3 | "benchee_dsl": {:hex, :benchee_dsl, "0.5.3", "f214a33076c79b205780e21a9712f977e1aaf2402acb8b59ff99cd4e9c151499", [:mix], [{:benchee, ">= 0.99.0 and < 2.0.0", [hex: :benchee, repo: "hexpm", optional: false]}, {:kino, "~> 0.6", [hex: :kino, repo: "hexpm", optional: false]}], "hexpm", "68b829aa4d2782a225dcb0b9652e440b49264b78aa37fe4e120fe784bbfcb466"}, 4 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 5 | "credo": {:hex, :credo, "1.7.10", "6e64fe59be8da5e30a1b96273b247b5cf1cc9e336b5fd66302a64b25749ad44d", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "71fbc9a6b8be21d993deca85bf151df023a3097b01e09a2809d460348561d8cd"}, 6 | "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, 7 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 8 | "earmark_parser": {:hex, :earmark_parser, "1.4.42", "f23d856f41919f17cd06a493923a722d87a2d684f143a1e663c04a2b93100682", [:mix], [], "hexpm", "6915b6ca369b5f7346636a2f41c6a6d78b5af419d61a611079189233358b8b8b"}, 9 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 10 | "ex_doc": {:hex, :ex_doc, "0.36.1", "4197d034f93e0b89ec79fac56e226107824adcce8d2dd0a26f5ed3a95efc36b1", [:mix], [{:earmark_parser, "~> 1.4.42", [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", "d7d26a7cf965dacadcd48f9fa7b5953d7d0cfa3b44fa7a65514427da44eafd89"}, 11 | "excoveralls": {:hex, :excoveralls, "0.18.3", "bca47a24d69a3179951f51f1db6d3ed63bca9017f476fe520eb78602d45f7756", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "746f404fcd09d5029f1b211739afb8fb8575d775b21f6a3908e7ce3e640724c6"}, 12 | "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, 13 | "fss": {:hex, :fss, "0.1.1", "9db2344dbbb5d555ce442ac7c2f82dd975b605b50d169314a20f08ed21e08642", [:mix], [], "hexpm", "78ad5955c7919c3764065b21144913df7515d52e228c09427a004afe9c1a16b0"}, 14 | "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, 15 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 16 | "kino": {:hex, :kino, "0.14.2", "46c5da03f2d62dc119ec5e1c1493f409f08998eac26015ecdfae322ffff46d76", [:mix], [{:fss, "~> 0.1.0", [hex: :fss, repo: "hexpm", optional: false]}, {:nx, "~> 0.1", [hex: :nx, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}, {:table, "~> 0.1.2", [hex: :table, repo: "hexpm", optional: false]}], "hexpm", "f54924dd0800ee8b291fe437f942889e90309eb3541739578476f53c1d79c968"}, 17 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 18 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [: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", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 19 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 20 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 21 | "sourceror": {:hex, :sourceror, "1.7.1", "599d78f4cc2be7d55c9c4fd0a8d772fd0478e3a50e726697c20d13d02aa056d4", [:mix], [], "hexpm", "cd6f268fe29fa00afbc535e215158680a0662b357dc784646d7dff28ac65a0fc"}, 22 | "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"}, 23 | "table": {:hex, :table, "0.1.2", "87ad1125f5b70c5dea0307aa633194083eb5182ec537efc94e96af08937e14a8", [:mix], [], "hexpm", "7e99bc7efef806315c7e65640724bf165c3061cdc5d854060f74468367065029"}, 24 | "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, 25 | } 26 | -------------------------------------------------------------------------------- /test/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: [ 3 | "{rewrite,support}/**/*.{ex,exs}", 4 | "fixtures/source/**/*.{ex,exs}", 5 | "*.{ex,exs}", 6 | "hook/**/*.exs" 7 | ] 8 | ] 9 | -------------------------------------------------------------------------------- /test/fixtures/error.ex: -------------------------------------------------------------------------------- 1 | a = x + .foo() 2 | -------------------------------------------------------------------------------- /test/fixtures/source/double.ex: -------------------------------------------------------------------------------- 1 | defmodule Double.Foo do 2 | def foo(x) do 3 | x * 2 4 | end 5 | end 6 | 7 | defmodule Double.Bar do 8 | def foo(x) do 9 | bar(x) 10 | end 11 | 12 | defp bar(x), do: x * 2 13 | end 14 | -------------------------------------------------------------------------------- /test/fixtures/source/hello.txt: -------------------------------------------------------------------------------- 1 | hello 2 | -------------------------------------------------------------------------------- /test/fixtures/source/module_ast_contained.ex: -------------------------------------------------------------------------------- 1 | defmodule ModuleAstContained do 2 | def dynamic_module_ast() do 3 | module_name = DynamicModule 4 | 5 | ast = 6 | quote do 7 | defmodule unquote(module_name) do 8 | def hello() do 9 | :world 10 | end 11 | end 12 | end 13 | 14 | ast 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/fixtures/source/nested.ex: -------------------------------------------------------------------------------- 1 | defmodule Double.Foo do 2 | defmodule Bar do 3 | def foo(x) do 4 | bar(x) 5 | end 6 | 7 | defp bar(x), do: x * 2 8 | end 9 | 10 | def foo(x) do 11 | Bar.foo(x) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/fixtures/source/simple.ex: -------------------------------------------------------------------------------- 1 | defmodule MyApp.Simple do 2 | def foo(x) do 3 | x * 2 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /test/hook/dot_formatter_updater_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Rewrite.Hook.DotFormatterUpdaterTest do 2 | use RewriteCase, async: false 3 | 4 | alias Rewrite.Hook.DotFormatterUpdater 5 | alias Rewrite.Source 6 | 7 | # doctest Rewrite.Hook.DotFormatterUpdater 8 | 9 | @moduletag :tmp_dir 10 | 11 | describe "DotFormatterUpdater hook" do 12 | test "updates dot_formatter", context do 13 | in_tmp context do 14 | project = 15 | "**/*" 16 | |> Rewrite.new!(hooks: [DotFormatterUpdater]) 17 | |> Rewrite.new_source!("foo.ex", "foo bar baz") 18 | |> Rewrite.format!() 19 | 20 | assert read!(project, "foo.ex") == "foo(bar(baz))\n" 21 | 22 | project = 23 | project 24 | |> Rewrite.new_source!( 25 | ".formatter.exs", 26 | ~s|[inputs: "**/*", locals_without_parens: [foo: 1]]| 27 | ) 28 | |> Rewrite.update!("foo.ex", fn source -> 29 | Source.update(source, :content, "foo bar baz") 30 | end) 31 | |> Rewrite.format!() 32 | 33 | assert read!(project, "foo.ex") == "foo bar(baz)\n" 34 | 35 | project = 36 | project 37 | |> Rewrite.update!(".formatter.exs", fn source -> 38 | Source.update(source, :content, ~s|[inputs: "**/*", locals_without_parens: [bar: 1]]|) 39 | end) 40 | |> Rewrite.update!("foo.ex", fn source -> 41 | Source.update(source, :content, "foo bar baz") 42 | end) 43 | |> Rewrite.format!(by: TheFormatter) 44 | 45 | assert read!(project, "foo.ex") == "foo(bar baz)\n" 46 | 47 | project = 48 | project 49 | |> Rewrite.new_source!("bar.ex", "") 50 | |> Rewrite.update!("bar.ex", fn source -> 51 | quoted = Sourceror.parse_string!("bar baz foo") 52 | Source.update(source, :quoted, quoted, dot_formatter: project.dot_formatter) 53 | end) 54 | 55 | assert read!(project, "bar.ex") == "bar baz(foo)\n" 56 | 57 | assert Rewrite.source!(project, "foo.ex") |> Map.fetch!(:history) == 58 | [ 59 | {:content, TheFormatter, "foo bar baz"}, 60 | {:content, Rewrite, "foo bar(baz)\n"}, 61 | {:content, Rewrite, "foo bar baz"}, 62 | {:content, Rewrite, "foo(bar(baz))\n"}, 63 | {:content, Rewrite, "foo bar baz"} 64 | ] 65 | 66 | assert Rewrite.source!(project, "bar.ex") |> Map.fetch!(:history) == 67 | [{:content, Rewrite, ""}] 68 | end 69 | end 70 | 71 | test "reads formatter", context do 72 | in_tmp context do 73 | write!( 74 | ".formatter.exs": """ 75 | [inputs: "**/*", locals_without_parens: [foo: 1]] 76 | """ 77 | ) 78 | 79 | dot_formatter = 80 | "**/*" 81 | |> Rewrite.new!(hooks: [DotFormatterUpdater]) 82 | |> Rewrite.dot_formatter() 83 | 84 | assert dot_formatter.locals_without_parens == [foo: 1] 85 | end 86 | end 87 | 88 | test "uses formatter", context do 89 | in_tmp context do 90 | write!( 91 | ".formatter.exs": """ 92 | [inputs: "**/*", locals_without_parens: [foo: 1]] 93 | """, 94 | "lib/foo.ex": """ 95 | foo bar baz 96 | """ 97 | ) 98 | 99 | project = Rewrite.new!("**/*", hooks: [DotFormatterUpdater]) 100 | 101 | assert read!(project, "lib/foo.ex") == "foo bar baz\n" 102 | 103 | assert project = Rewrite.format!(project) 104 | 105 | assert read!(project, "lib/foo.ex") == "foo bar(baz)\n" 106 | end 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /test/rewrite/source/ex_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Rewrite.Source.ExTest do 2 | use RewriteCase 3 | 4 | alias Rewrite.DotFormatter 5 | alias Rewrite.Source 6 | alias Rewrite.Source.Ex 7 | 8 | doctest Rewrite.Source.Ex 9 | 10 | describe "read/1" do 11 | test "creates new source" do 12 | assert %Source{filetype: ex} = Source.Ex.read!("test/fixtures/source/simple.ex") 13 | assert %Ex{quoted: {:defmodule, _meta, _args}} = ex 14 | end 15 | end 16 | 17 | describe "from_string/3" do 18 | test "creates an ex source from string" do 19 | assert %Source{} = source = Source.Ex.from_string(":a") 20 | assert is_struct(source.filetype, Ex) 21 | assert source.path == nil 22 | assert source.owner == Rewrite 23 | end 24 | 25 | test "creates an ex source from string with path" do 26 | assert %Source{} = source = Source.Ex.from_string(":a", path: "test.ex") 27 | assert source.path == "test.ex" 28 | end 29 | 30 | test "creates an ex source from string with path and opts" do 31 | assert %Source{} = source = Source.Ex.from_string(":a", path: "test.ex", owner: Meins) 32 | assert source.owner == Meins 33 | end 34 | end 35 | 36 | describe "handle_update/2" do 37 | test "updates quoted" do 38 | source = Source.Ex.from_string(":a", path: "a.exs") 39 | quoted = Sourceror.parse_string!(":x") 40 | source = Source.update(source, :quoted, quoted) 41 | 42 | assert source.content == ":x\n" 43 | end 44 | 45 | test "updates quoted with function" do 46 | source = Source.Ex.from_string(":a", path: "a.exs") 47 | 48 | source = 49 | Source.update(source, :quoted, fn quoted -> 50 | {:__block__, meta, [atom]} = quoted 51 | {:__block__, meta, [{:ok, atom}]} 52 | end) 53 | 54 | assert source.content == "{:ok, :a}\n" 55 | end 56 | 57 | test "updates quoted with resync_quoted: true" do 58 | source = Source.Ex.from_string(":a", path: "a.exs") 59 | 60 | {:ok, quoted} = 61 | Code.string_to_quoted(""" 62 | defmodule Test do 63 | def test, do: :test 64 | end 65 | """) 66 | 67 | source = Source.update(source, :quoted, quoted) 68 | 69 | assert Source.get(source, :quoted) != quoted 70 | end 71 | 72 | test "updates quoted with resync_quoted: false" do 73 | source = Source.Ex.read!("test/fixtures/source/simple.ex", resync_quoted: false) 74 | 75 | {:ok, quoted} = 76 | Code.string_to_quoted(""" 77 | defmodule Test do 78 | def test, do: :test 79 | end 80 | """) 81 | 82 | source = Source.update(source, :quoted, quoted) 83 | 84 | assert source.filetype.quoted == quoted 85 | assert Source.get(source, :quoted) == quoted 86 | end 87 | 88 | test "updates quoted with :dot_formatter" do 89 | dot_formatter = DotFormatter.from_formatter_opts(locals_without_parens: [bar: 1]) 90 | source = Source.Ex.from_string("", path: "a.ex") 91 | quoted = Sourceror.parse_string!("foo bar baz") 92 | 93 | source = Source.update(source, :quoted, quoted, dot_formatter: dot_formatter) 94 | 95 | assert source.content == "foo(bar baz)\n" 96 | end 97 | 98 | test "updateds content" do 99 | source = Source.Ex.from_string(":a", path: "a.exs") 100 | assert Source.get(source, :quoted) == Sourceror.parse_string!(":a") 101 | 102 | source = Source.update(source, :content, ":x") 103 | assert Source.get(source, :quoted) == Sourceror.parse_string!(":x") 104 | end 105 | 106 | test "updates content without changing" do 107 | source = Source.Ex.from_string(":a", path: "a.exs") 108 | source = Source.update(source, :content, ":a") 109 | assert Source.get(source, :content) == ":a" 110 | end 111 | 112 | test "raises an error" do 113 | source = Source.Ex.from_string(":a") 114 | message = ~r/unexpected.reserved.word:.end/m 115 | 116 | assert_raise SyntaxError, message, fn -> 117 | Source.update(source, :content, ":ok end") 118 | end 119 | end 120 | 121 | @tag :tmp_dir 122 | test "updates content with the rewrite dot formatter", context do 123 | in_tmp context do 124 | write!( 125 | ".formatter.exs": """ 126 | [ 127 | inputs: ["a.ex"], 128 | locals_without_parens: [foo: 1] 129 | ] 130 | """, 131 | "a.ex": """ 132 | foo bar baz 133 | """ 134 | ) 135 | 136 | rewrite = Rewrite.new!("**/*", dot_formatter: DotFormatter.read!()) 137 | dot_formatter = Rewrite.dot_formatter(rewrite) 138 | 139 | assert read!(rewrite, "a.ex") == "foo bar baz\n" 140 | 141 | source = Rewrite.source!(rewrite, "a.ex") 142 | source = Source.update(source, :content, "foo bar baz", dot_formatter: dot_formatter) 143 | assert Source.get(source, :content) == "foo bar baz" 144 | 145 | quoted = Sourceror.parse_string!("foo baz bar") 146 | source = Source.update(source, :quoted, quoted, dot_formatter: dot_formatter) 147 | assert Source.get(source, :content) == "foo baz(bar)\n" 148 | end 149 | end 150 | end 151 | 152 | describe "handle_update/3" do 153 | test "updates source with quoted expression" do 154 | source = Source.Ex.from_string(":a") 155 | quoted = Sourceror.parse_string!(":x") 156 | assert %Source{content: content} = Source.update(source, :quoted, quoted) 157 | assert content == ":x\n" 158 | end 159 | 160 | test "formats content" do 161 | source = Source.Ex.from_string(":a") 162 | 163 | quoted = 164 | Sourceror.parse_string!(""" 165 | defmodule Foo do 166 | end 167 | """) 168 | 169 | assert %Source{content: content} = Source.update(source, :quoted, quoted) 170 | 171 | assert content == """ 172 | defmodule Foo do 173 | end 174 | """ 175 | end 176 | 177 | test "does not updates" do 178 | code = ":a" 179 | source = Source.Ex.from_string(code) 180 | quoted = Sourceror.parse_string!(code) 181 | assert source = Source.update(source, :quoted, quoted) 182 | assert Source.updated?(source) == false 183 | end 184 | end 185 | 186 | describe "modules/2" do 187 | test "retruns a list with one module" do 188 | source = Source.Ex.read!("test/fixtures/source/simple.ex") 189 | 190 | assert Source.Ex.modules(source) == [MyApp.Simple] 191 | end 192 | 193 | test "retruns a list of modules" do 194 | source = Source.Ex.read!("test/fixtures/source/double.ex") 195 | 196 | assert Source.Ex.modules(source) == [Double.Bar, Double.Foo] 197 | end 198 | 199 | test "retruns an empty list" do 200 | source = Source.Ex.from_string(":a") 201 | 202 | assert Source.Ex.modules(source) == [] 203 | end 204 | 205 | test "returns a list of modules for an older version" do 206 | source = Source.Ex.read!("test/fixtures/source/simple.ex") 207 | source = Source.update(source, :content, ":a") 208 | 209 | assert Source.Ex.modules(source) == [] 210 | assert Source.Ex.modules(source, 2) == [] 211 | assert Source.Ex.modules(source, 1) == [MyApp.Simple] 212 | end 213 | end 214 | 215 | test "inspect" do 216 | source = Source.Ex.from_string(":a") 217 | assert inspect(source.filetype) == "#Rewrite.Source.Ex<.ex,.exs>" 218 | end 219 | end 220 | -------------------------------------------------------------------------------- /test/rewrite/source_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Rewrite.SourceTest do 2 | use ExUnit.Case 3 | 4 | alias Rewrite.DotFormatter 5 | alias Rewrite.Source 6 | alias Rewrite.SourceError 7 | alias Rewrite.SourceKeyError 8 | 9 | doctest Rewrite.Source 10 | 11 | describe "read/1" do 12 | test "creates new source" do 13 | path = "test/fixtures/source/hello.txt" 14 | mtime = File.stat!(path, time: :posix).mtime 15 | hash = hash(path) 16 | 17 | assert Source.read!(path) == %Source{ 18 | from: :file, 19 | owner: Rewrite, 20 | path: path, 21 | content: "hello\n", 22 | filetype: nil, 23 | hash: hash, 24 | issues: [], 25 | private: %{}, 26 | timestamp: mtime, 27 | history: [] 28 | } 29 | end 30 | 31 | test "creates new source from full path" do 32 | path = Path.join(File.cwd!(), "test/fixtures/source/hello.txt") 33 | mtime = File.stat!(path, time: :posix).mtime 34 | hash = hash(path) 35 | 36 | assert Source.read!(path) == %Source{ 37 | from: :file, 38 | owner: Rewrite, 39 | path: path, 40 | content: "hello\n", 41 | filetype: nil, 42 | hash: hash, 43 | issues: [], 44 | private: %{}, 45 | timestamp: mtime, 46 | history: [] 47 | } 48 | end 49 | end 50 | 51 | describe "from_string/2" do 52 | test "creates a source from code" do 53 | content = "foo\n" 54 | source = Source.from_string(content) 55 | 56 | assert source.content == content 57 | assert source.path == nil 58 | end 59 | end 60 | 61 | describe "owner/1" do 62 | test "returns the owner of a source" do 63 | source = Source.from_string("hello") 64 | assert Source.owner(source) == Rewrite 65 | end 66 | end 67 | 68 | describe "rm/1" do 69 | @describetag :tmp_dir 70 | 71 | test "deletes file", %{tmp_dir: tmp_dir} do 72 | File.cd!(tmp_dir, fn -> 73 | File.write!("a.exs", ":a") 74 | source = Source.read!("a.exs") 75 | 76 | assert Source.rm(source) == :ok 77 | 78 | assert File.exists?("a.exs") == false 79 | end) 80 | end 81 | 82 | test "returns a posix error", %{tmp_dir: tmp_dir} do 83 | File.cd!(tmp_dir, fn -> 84 | File.write!("a.exs", ":a") 85 | source = Source.read!("a.exs") 86 | 87 | assert Source.rm(source) == :ok 88 | 89 | assert Source.rm(source) == 90 | {:error, %Rewrite.SourceError{reason: :enoent, path: "a.exs", action: :rm}} 91 | end) 92 | end 93 | 94 | test "returns an error" do 95 | source = Source.from_string(":a") 96 | 97 | assert Source.rm(source) == 98 | {:error, %Rewrite.SourceError{reason: :nopath, path: nil, action: :rm}} 99 | end 100 | end 101 | 102 | describe "rm!/1" do 103 | @describetag :tmp_dir 104 | 105 | test "deletes file", %{tmp_dir: tmp_dir} do 106 | File.cd!(tmp_dir, fn -> 107 | File.write!("a.exs", ":a") 108 | source = Source.read!("a.exs") 109 | 110 | assert Source.rm!(source) == :ok 111 | 112 | assert File.exists?("a.exs") == false 113 | end) 114 | end 115 | 116 | test "raises an exception for a posix error", %{tmp_dir: tmp_dir} do 117 | File.cd!(tmp_dir, fn -> 118 | File.write!("a.exs", ":a") 119 | source = Source.read!("a.exs") 120 | 121 | assert Source.rm!(source) == :ok 122 | 123 | messsage = ~s'could not remove file "a.exs": no such file or directory' 124 | 125 | assert_raise SourceError, messsage, fn -> 126 | Source.rm!(source) == 127 | {:error, %Rewrite.SourceError{reason: :nopath, path: nil, action: :rm}} 128 | end 129 | end) 130 | end 131 | 132 | test "raises an exception" do 133 | source = Source.from_string(":a") 134 | 135 | messsage = "could not remove file: no path found" 136 | 137 | assert_raise SourceError, messsage, fn -> 138 | Source.rm!(source) == 139 | {:error, %Rewrite.SourceError{reason: :nopath, path: nil, action: :rm}} 140 | end 141 | end 142 | end 143 | 144 | describe "write/1" do 145 | @describetag :tmp_dir 146 | 147 | test "writes changes to disk", %{tmp_dir: tmp_dir} do 148 | File.cd!(tmp_dir, fn -> 149 | File.write!("a.txt", "a") 150 | source = "a.txt" |> Source.read!() |> Source.update(:content, "b") 151 | 152 | assert {:ok, _updated} = Source.write(source) 153 | 154 | assert File.read!(source.path) == "b\n" 155 | end) 156 | end 157 | 158 | test "writes not to disk", %{tmp_dir: tmp_dir} do 159 | File.cd!(tmp_dir, fn -> 160 | path = "a.txt" 161 | File.write!(path, "a") 162 | File.touch!(path, 1) 163 | stats = File.stat!(path) 164 | source = Source.read!(path) 165 | 166 | assert {:ok, ^source} = Source.write(source) 167 | 168 | assert File.stat!(path) == stats 169 | end) 170 | end 171 | end 172 | 173 | describe "write!/1" do 174 | @describetag :tmp_dir 175 | 176 | test "writes changes to disk", %{tmp_dir: tmp_dir} do 177 | File.cd!(tmp_dir, fn -> 178 | File.write!("a.txt", "a") 179 | source = "a.txt" |> Source.read!() |> Source.update(:content, "b") 180 | 181 | assert saved = Source.write!(source) 182 | 183 | assert File.read!(source.path) == "b\n" 184 | assert Source.updated?(saved) == false 185 | end) 186 | end 187 | 188 | test "raises an exception when old file can't be removed", %{tmp_dir: tmp_dir} do 189 | File.cd!(tmp_dir, fn -> 190 | source = "a" |> Source.from_string(path: "a.txt") |> Source.update(:path, "b.txt") 191 | 192 | message = ~s'could not write to file "a.txt": no such file or directory' 193 | 194 | assert_raise SourceError, message, fn -> 195 | Source.write!(source) 196 | end 197 | 198 | assert File.exists?("b.exs") == false 199 | end) 200 | end 201 | 202 | test "raises an exception when file changed", %{tmp_dir: tmp_dir} do 203 | File.cd!(tmp_dir, fn -> 204 | path = "a.txt" 205 | File.write!(path, "a") 206 | source = path |> Source.read!() |> Source.update(:content, "x") 207 | File.write!(path, "b") 208 | 209 | message = ~s'could not write to file "a.txt": file changed since reading' 210 | 211 | assert_raise SourceError, message, fn -> 212 | Source.write!(source) 213 | end 214 | end) 215 | end 216 | end 217 | 218 | describe "update/4" do 219 | test "does not update source when code not changed" do 220 | source = Source.read!("test/fixtures/source/hello.txt") 221 | updated = Source.update(source, :content, source.content, by: Test) 222 | 223 | assert Source.updated?(updated) == false 224 | end 225 | 226 | test "updates the content" do 227 | path = "test/fixtures/source/hello.txt" 228 | txt = File.read!(path) 229 | new = "bye" 230 | 231 | source = 232 | path 233 | |> Source.read!() 234 | |> Source.update(:content, new, by: Tester) 235 | 236 | assert source.history == [{:content, Tester, txt}] 237 | assert source.content == new 238 | assert updated_timestamp?(source) 239 | end 240 | 241 | test "updates the path" do 242 | path = "test/fixtures/source/hello.txt" 243 | new = "test/fixtures/source/bye.txt" 244 | 245 | source = 246 | path 247 | |> Source.read!() 248 | |> Source.update(:path, new) 249 | 250 | assert source.history == [{:path, Rewrite, path}] 251 | assert source.path == new 252 | assert updated_timestamp?(source) 253 | end 254 | 255 | test "updates with filetype value" do 256 | source = ":a" |> Source.Ex.from_string(path: "test/a.ex") |> Source.touch(now(-10)) 257 | quoted = Sourceror.parse_string!(":b") 258 | 259 | assert source = Source.update(source, :quoted, quoted) 260 | assert source.filetype != nil 261 | assert Source.get(source, :content) == ":b\n" 262 | assert Source.updated?(source) == true 263 | assert updated_timestamp?(source) 264 | end 265 | 266 | test "does not update with filetype value without any changes" do 267 | timestamp = now(-10) 268 | source = ":a" |> Source.Ex.from_string(path: "test/a.ex") |> Source.touch(timestamp) 269 | quoted = Sourceror.parse_string!(":a") 270 | 271 | assert source = Source.update(source, :quoted, quoted) 272 | assert source.filetype != nil 273 | assert Source.get(source, :content) == ":a" 274 | assert Source.updated?(source) == false 275 | assert source.timestamp == timestamp 276 | end 277 | end 278 | 279 | describe "get/3" do 280 | test "returns the content for the given version without content changes" do 281 | path = "test/fixtures/source/hello.txt" 282 | content = File.read!(path) 283 | 284 | source = 285 | path 286 | |> Source.read!() 287 | |> Source.update(:path, "a.txt") 288 | |> Source.update(:path, "b.txt") 289 | 290 | assert Source.get(source, :content, 1) == content 291 | assert Source.get(source, :content, 2) == content 292 | assert Source.get(source, :content, 3) == content 293 | end 294 | 295 | test "returns the content for given version" do 296 | content = "foo" 297 | 298 | source = 299 | content 300 | |> Source.from_string() 301 | |> Source.update(:content, "bar") 302 | |> Source.update(:content, "baz") 303 | 304 | assert Source.get(source, :content, 1) == content 305 | assert Source.get(source, :content, 2) == "bar" 306 | assert Source.get(source, :content, 3) == "baz" 307 | end 308 | 309 | test "returns path" do 310 | path = "test/fixtures/source/hello.txt" 311 | source = Source.read!(path) 312 | 313 | assert Source.get(source, :path) == path 314 | end 315 | 316 | test "returns current path" do 317 | source = Source.read!("test/fixtures/source/hello.txt") 318 | path = "test/fixtures/source/new.ex" 319 | 320 | source = Source.update(source, :path, path) 321 | 322 | assert Source.get(source, :path) == path 323 | end 324 | 325 | test "returns the path for the given version" do 326 | path = "test/fixtures/source/hello.txt" 327 | 328 | source = 329 | path 330 | |> Source.read!() 331 | |> Source.update(:path, "a.txt") 332 | |> Source.update(:path, "b.txt") 333 | |> Source.update(:path, "c.txt") 334 | 335 | assert Source.get(source, :path, 1) == path 336 | assert Source.get(source, :path, 2) == "a.txt" 337 | assert Source.get(source, :path, 3) == "b.txt" 338 | assert Source.get(source, :path, 4) == "c.txt" 339 | end 340 | 341 | test "returns the path for given version without path changes" do 342 | path = "test/fixtures/source/hello.txt" 343 | 344 | source = 345 | path 346 | |> Source.read!() 347 | |> Source.update(:content, "bye") 348 | |> Source.update(:content, "hi") 349 | 350 | assert Source.get(source, :path, 1) == path 351 | assert Source.get(source, :path, 2) == path 352 | assert Source.get(source, :path, 3) == path 353 | end 354 | 355 | test "returns quoted from filetype ex" do 356 | source = ":a" |> Source.Ex.from_string() |> Source.update(:content, ":b") 357 | 358 | assert Source.get(source, :quoted) == 359 | {:__block__, [trailing_comments: [], leading_comments: [], line: 1, column: 1], 360 | [:b]} 361 | 362 | assert Source.get(source, :quoted, 1) == 363 | {:__block__, [trailing_comments: [], leading_comments: [], line: 1, column: 1], 364 | [:a]} 365 | end 366 | 367 | test "raises a SourceKeyError" do 368 | source = Source.from_string("test") 369 | 370 | message = """ 371 | key :unknown not found in source. This function is just definded for the \ 372 | keys :content, :path and keys provided by filetype.\ 373 | """ 374 | 375 | assert_raise SourceKeyError, message, fn -> 376 | Source.get(source, :unknown) 377 | end 378 | 379 | assert_raise SourceKeyError, message, fn -> 380 | Source.get(source, :unknown, 1) 381 | end 382 | end 383 | 384 | test "raises a SourceKeyError for a source with filetype" do 385 | source = Source.Ex.from_string("test") 386 | 387 | message = """ 388 | key :unknown not found in source. This function is just definded for the \ 389 | keys :content, :path and keys provided by filetype.\ 390 | """ 391 | 392 | assert_raise SourceKeyError, message, fn -> 393 | Source.get(source, :unknown) 394 | end 395 | 396 | assert_raise SourceKeyError, message, fn -> 397 | Source.get(source, :unknown, 1) 398 | end 399 | end 400 | end 401 | 402 | describe "put_private/3" do 403 | test "updates the private map" do 404 | source = Source.from_string("a + b\n") 405 | 406 | assert source = Source.put_private(source, :any_key, :any_value) 407 | assert source.private[:any_key] == :any_value 408 | end 409 | end 410 | 411 | describe "undo/2" do 412 | test "returns unchanged source when source not updated" do 413 | source = Source.from_string("test") 414 | 415 | assert Source.undo(source) == source 416 | assert Source.undo(source, 5) == source 417 | end 418 | 419 | test "returns first source when source was updated once" do 420 | source = Source.from_string("test") 421 | updated = Source.update(source, :content, "changed") 422 | undo = Source.undo(updated) 423 | 424 | assert undo == source 425 | assert Source.updated?(undo) == false 426 | end 427 | 428 | test "returns previous source" do 429 | a = Source.from_string("test-a") 430 | b = Source.update(a, :content, "test-b") 431 | c = Source.update(b, :path, "test/foo.txt") 432 | d = Source.update(c, :content, "test-d") 433 | 434 | assert Source.undo(d) == c 435 | assert Source.undo(d, 2) == b 436 | assert Source.undo(d, 3) == a 437 | assert Source.undo(d, 9) == a 438 | end 439 | 440 | test "returns previous Elixir source" do 441 | a = Source.Ex.from_string(":a") 442 | b = Source.update(a, :content, ":b") 443 | c = Source.update(b, :path, "test/foo.txt") 444 | d = Source.update(c, :content, ":d") 445 | 446 | assert Source.undo(d) == c 447 | assert Source.undo(d, 2) == b 448 | assert Source.undo(d, 3) == a 449 | assert Source.undo(d, 9) == a 450 | end 451 | end 452 | 453 | describe "issues/1" do 454 | test "returns issues" do 455 | source = 456 | Source.from_string("test") 457 | |> Source.add_issue(:foo) 458 | |> Source.add_issue(:bar) 459 | 460 | assert Source.issues(source) == [:bar, :foo] 461 | end 462 | end 463 | 464 | describe "format/2" do 465 | test "formats a source" do 466 | source = Source.Ex.from_string("foo bar baz") 467 | assert {:ok, source} = Source.format(source) 468 | assert source.content == "foo(bar(baz))\n" 469 | assert source.owner == Rewrite 470 | assert source.history == [{:content, Rewrite, "foo bar baz"}] 471 | end 472 | 473 | test "does not updates source when not needed" do 474 | source = Source.Ex.from_string(":foo\n") 475 | assert {:ok, source} = Source.format(source) 476 | assert source.content == ":foo\n" 477 | assert source.history == [] 478 | end 479 | 480 | test "formats a source with owner and by" do 481 | source = Source.Ex.from_string("foo bar baz", owner: Walter) 482 | assert {:ok, source} = Source.format(source, by: Felix) 483 | assert source.content == "foo(bar(baz))\n" 484 | assert source.owner == Walter 485 | assert source.history == [{:content, Felix, "foo bar baz"}] 486 | end 487 | 488 | test "returns an error" do 489 | source = Source.from_string("x =", path: "no.ex") 490 | assert {:error, _error} = Source.format(source) 491 | end 492 | 493 | test "formats a source with source.dot_formatter" do 494 | dot_formatter = DotFormatter.from_formatter_opts(locals_without_parens: [foo: 1]) 495 | source = Source.Ex.from_string("foo bar baz") 496 | assert {:ok, source} = Source.format(source, dot_formatter: dot_formatter) 497 | assert source.content == "foo bar(baz)\n" 498 | end 499 | end 500 | 501 | describe "format!/2" do 502 | test "formats a source" do 503 | source = Source.Ex.from_string("foo bar baz") 504 | assert source = Source.format!(source) 505 | assert source.content == "foo(bar(baz))\n" 506 | end 507 | 508 | test "raises an exception" do 509 | source = Source.from_string("x =", path: "no.ex") 510 | 511 | assert_raise TokenMissingError, fn -> 512 | Source.format!(source) 513 | end 514 | end 515 | end 516 | 517 | test "inspect" do 518 | source = Source.from_string("test", path: "foo.ex") 519 | assert inspect(source) == "#Rewrite.Source" 520 | end 521 | 522 | defp hash(path) do 523 | content = File.read!(path) 524 | :erlang.phash2({path, content}) 525 | end 526 | 527 | defp now(diff \\ 0) do 528 | now = DateTime.utc_now() |> DateTime.to_unix() 529 | now + diff 530 | end 531 | 532 | defp updated_timestamp?(source) do 533 | if File.regular?(source.path) do 534 | mtime = File.stat!(source.path, time: :posix).mtime 535 | assert mtime < source.timestamp 536 | end 537 | 538 | assert_in_delta source.timestamp, now(), 1 539 | end 540 | end 541 | -------------------------------------------------------------------------------- /test/rewrite_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RewriteTest do 2 | use RewriteCase, async: false 3 | 4 | import GlobEx.Sigils 5 | 6 | alias Rewrite.DotFormatter 7 | alias Rewrite.DotFormatterError 8 | alias Rewrite.Error 9 | alias Rewrite.Source 10 | alias Rewrite.SourceError 11 | alias Rewrite.UpdateError 12 | 13 | doctest Rewrite 14 | 15 | describe "put!/1" do 16 | test "adds a source to the project" do 17 | project = Rewrite.new() 18 | 19 | assert project = Rewrite.put!(project, Source.from_string(":a", path: "a.exs")) 20 | assert map_size(project.sources) == 1 21 | end 22 | 23 | test "raises an exception when path is nil" do 24 | project = Rewrite.new() 25 | 26 | message = "no path found" 27 | 28 | assert_raise Error, message, fn -> 29 | Rewrite.put!(project, Source.Ex.from_string(":a")) 30 | end 31 | end 32 | 33 | test "raises an exception when overwrites" do 34 | {:ok, project} = 35 | Rewrite.from_sources([ 36 | Source.from_string(":a", path: "a.exs") 37 | ]) 38 | 39 | message = ~s'overwrites "a.exs"' 40 | 41 | assert_raise Error, message, fn -> 42 | Rewrite.put!(project, Source.from_string(":b", path: "a.exs")) 43 | end 44 | end 45 | end 46 | 47 | describe "delete/2" do 48 | @describetag :tmp_dir 49 | 50 | test "removes a source file by path", context do 51 | in_tmp context do 52 | path = "a.exs" 53 | File.write!(path, ":a") 54 | project = Rewrite.new!("**") 55 | project = Rewrite.delete(project, path) 56 | 57 | assert Enum.empty?(project) == true 58 | assert File.exists?(path) == true 59 | 60 | Rewrite.write_all(project) 61 | assert File.exists?(path) == true 62 | end 63 | end 64 | end 65 | 66 | describe "move/4" do 67 | @describetag :tmp_dir 68 | 69 | test "moves a file to a new location", context do 70 | in_tmp context do 71 | from = "a.exs" 72 | to = "foo/a.exs" 73 | File.write!(from, ":a") 74 | project = Rewrite.new!("**") 75 | 76 | {:ok, project} = Rewrite.move(project, from, to) 77 | 78 | assert {:error, _error} = Rewrite.source(project, from) 79 | assert {:ok, _source} = Rewrite.source(project, to) 80 | 81 | Rewrite.write_all(project) 82 | 83 | assert File.exists?(from) == false 84 | assert File.read!(to) == ":a\n" 85 | end 86 | end 87 | 88 | test "moves a source to a new location", context do 89 | in_tmp context do 90 | from = "a.exs" 91 | to = "foo/a.exs" 92 | File.write!(from, ":a") 93 | project = Rewrite.new!("**") 94 | source = Rewrite.source!(project, from) 95 | 96 | {:ok, project} = Rewrite.move(project, source, to) 97 | 98 | assert {:error, _error} = Rewrite.source(project, from) 99 | assert {:ok, _source} = Rewrite.source(project, to) 100 | 101 | Rewrite.write_all(project) 102 | 103 | assert File.exists?(from) == false 104 | assert File.read!(to) == ":a\n" 105 | end 106 | end 107 | 108 | test "swaps two files", context do 109 | in_tmp context do 110 | a = "a.exs" 111 | b = "b.exs" 112 | swap = "swap.exs" 113 | File.write!(a, ":a") 114 | File.write!(b, ":b") 115 | project = Rewrite.new!("**") 116 | 117 | {:ok, project} = Rewrite.move(project, a, swap) 118 | {:ok, project} = Rewrite.move(project, b, a) 119 | {:ok, project} = Rewrite.move(project, swap, b) 120 | 121 | assert {:ok, project} = Rewrite.write_all(project) 122 | 123 | assert Rewrite.paths(project) == ["a.exs", "b.exs"] 124 | 125 | assert File.read!(a) == ":b\n" 126 | assert File.read!(b) == ":a\n" 127 | assert File.exists?(swap) == false 128 | end 129 | end 130 | 131 | test "returns an error if from source not exists" do 132 | project = Rewrite.new() 133 | source = Rewrite.create_source(project, "foo.ex", "foo") 134 | 135 | assert {:error, %{reason: :nosource}} = Rewrite.move(project, "foo.ex", "bar.ex") 136 | assert {:error, %{reason: :nosource}} = Rewrite.move(project, source, "bar.ex") 137 | end 138 | 139 | test "returns an error if to source exists" do 140 | project = Rewrite.new() 141 | project = Rewrite.new_source!(project, "foo.ex", "foo") 142 | project = Rewrite.new_source!(project, "bar.ex", "bar") 143 | 144 | assert {:error, %{reason: :overwrites}} = Rewrite.move(project, "foo.ex", "bar.ex") 145 | end 146 | end 147 | 148 | describe "move!/4" do 149 | @describetag :tmp_dir 150 | 151 | test "moves a file to a new location", context do 152 | in_tmp context do 153 | from = "a.exs" 154 | to = "foo/a.exs" 155 | File.write!(from, ":a") 156 | project = Rewrite.new!("**") 157 | 158 | assert %Rewrite{} = Rewrite.move!(project, from, to) 159 | end 160 | end 161 | 162 | test "raises an exception" do 163 | project = Rewrite.new() 164 | 165 | assert_raise Error, ~s|no source found for "foo.ex"|, fn -> 166 | Rewrite.move!(project, "foo.ex", "bar.ex") 167 | end 168 | end 169 | end 170 | 171 | describe "rm!/2" do 172 | @describetag :tmp_dir 173 | 174 | test "removes a source file by path", context do 175 | in_tmp context do 176 | path = "a.exs" 177 | File.write!(path, ":a") 178 | project = Rewrite.new!("**") 179 | 180 | assert project = Rewrite.rm!(project, path) 181 | assert Enum.empty?(project) == true 182 | assert File.exists?(path) == false 183 | end 184 | end 185 | 186 | test "removes a source file by source", context do 187 | in_tmp context do 188 | path = "a.exs" 189 | File.write!(path, ":a") 190 | project = Rewrite.new!("**") 191 | source = Rewrite.source!(project, path) 192 | 193 | assert project = Rewrite.rm!(project, source) 194 | assert Enum.empty?(project) == true 195 | assert File.exists?(path) == false 196 | end 197 | end 198 | 199 | test "raises an exception when file operation fails", %{tmp_dir: tmp_dir} do 200 | File.cd!(tmp_dir, fn -> 201 | path = "a.exs" 202 | File.write!(path, ":a") 203 | project = Rewrite.new!("**") 204 | File.rm!(path) 205 | 206 | message = ~s'could not remove file "a.exs": no such file or directory' 207 | 208 | assert_raise SourceError, message, fn -> 209 | Rewrite.rm!(project, path) 210 | end 211 | end) 212 | end 213 | 214 | test "raises an exception when path not in project" do 215 | project = Rewrite.new() 216 | 217 | message = ~s'no source found for "a.exs"' 218 | 219 | assert_raise Error, message, fn -> 220 | Rewrite.rm!(project, "a.exs") 221 | end 222 | end 223 | end 224 | 225 | describe "new!/2" do 226 | test "creates a project from one file" do 227 | path = "test/fixtures/source/simple.ex" 228 | assert project = Rewrite.new!(path) 229 | assert Enum.count(project.sources) == 1 230 | assert %Source{filetype: %Source.Ex{}} = Rewrite.source!(project, path) 231 | end 232 | 233 | test "creates a project from one file without extensions" do 234 | path = "test/fixtures/source/simple.ex" 235 | assert project = Rewrite.new!(path, filetypes: []) 236 | assert Enum.count(project.sources) == 1 237 | assert %Source{filetype: nil} = Rewrite.source!(project, path) 238 | end 239 | 240 | test "creates a project from one file with given extensions" do 241 | ex = "test/fixtures/source/simple.ex" 242 | txt = "test/fixtures/source/hello.txt" 243 | 244 | assert project = 245 | Rewrite.new!([ex, txt], 246 | filetypes: [ 247 | {Source, owner: Test}, 248 | {Source.Ex, formatter_opts: [exclude_plugins: [Test]]} 249 | ] 250 | ) 251 | 252 | assert Enum.count(project.sources) == 2 253 | assert %Source{filetype: nil, owner: Test} = Rewrite.source!(project, txt) 254 | 255 | assert %Source{filetype: %Source.Ex{opts: opts}} = Rewrite.source!(project, ex) 256 | 257 | assert opts == [formatter_opts: [exclude_plugins: [Test]]] 258 | end 259 | 260 | test "creates a project from wildcard" do 261 | inputs = ["test/fixtures/source/*.ex"] 262 | assert project = Rewrite.new!(inputs) 263 | assert Enum.count(project.sources) == 4 264 | end 265 | 266 | test "creates a project from wildcards" do 267 | inputs = ["test/fixtures/source/d*.ex", "test/fixtures/source/s*.ex"] 268 | assert project = Rewrite.new!(inputs) 269 | assert Enum.count(project.sources) == 2 270 | end 271 | 272 | test "creates a project from glob" do 273 | inputs = [GlobEx.compile!("test/fixtures/source/*.ex")] 274 | assert project = Rewrite.new!(inputs) 275 | assert Enum.count(project.sources) == 4 276 | end 277 | 278 | test "creates a project from globs" do 279 | inputs = [ 280 | GlobEx.compile!("test/fixtures/source/d*.ex"), 281 | GlobEx.compile!("test/fixtures/source/s*.ex") 282 | ] 283 | 284 | assert project = Rewrite.new!(inputs) 285 | assert Enum.count(project.sources) == 2 286 | end 287 | 288 | test "throws an error for unreadable file" do 289 | file = "test/fixtures/source/simple.ex" 290 | File.chmod(file, 0o111) 291 | inputs = ["test/fixtures/source/*.ex"] 292 | message = ~s|could not read file "test/fixtures/source/simple.ex": permission denied| 293 | 294 | assert_raise File.Error, message, fn -> 295 | Rewrite.new!(inputs) 296 | end 297 | 298 | File.chmod(file, 0o644) 299 | end 300 | 301 | test "throws a syntax error in code" do 302 | inputs = ["test/fixtures/error.ex"] 303 | 304 | assert_raise SyntaxError, fn -> 305 | Rewrite.new!(inputs) 306 | end 307 | end 308 | 309 | @tag :tmp_dir 310 | test "excludes files by path and glob", context do 311 | in_tmp context do 312 | File.write!("foo.ex", ":foo") 313 | File.write!("bar.ex", ":bar") 314 | File.write!("baz.ex", ":baz") 315 | 316 | assert project = Rewrite.new!("**", exclude: ["foo.ex", ~g/baz*/]) 317 | assert project.sources |> Map.keys() == ["bar.ex"] 318 | assert project.excluded == ["foo.ex", "baz.ex"] 319 | end 320 | end 321 | 322 | @tag :tmp_dir 323 | test "excludes file by function", context do 324 | in_tmp context do 325 | File.write!("foo.ex", ":foo") 326 | File.write!("bar.ex", ":bar") 327 | 328 | exclude? = fn path -> path == "foo.ex" end 329 | 330 | assert project = Rewrite.new!("**", exclude: exclude?) 331 | assert project.sources |> Map.keys() == ["bar.ex"] 332 | assert project.excluded == ["foo.ex"] 333 | end 334 | end 335 | end 336 | 337 | describe "read!/2" do 338 | test "extends project" do 339 | project = Rewrite.new() 340 | 341 | assert project = Rewrite.read!(project, "test/fixtures/source/simple.ex") 342 | assert Enum.count(project.sources) == 1 343 | end 344 | 345 | test "extends project with full path" do 346 | project = Rewrite.new() 347 | path = Path.join(File.cwd!(), "test/fixtures/source/simple.ex") 348 | 349 | assert project = Rewrite.read!(project, path) 350 | assert Enum.count(project.sources) == 1 351 | end 352 | 353 | test "does not read already read files" do 354 | path = "test/fixtures/source/simple.ex" 355 | project = Rewrite.new!(path) 356 | 357 | assert project = Rewrite.read!(project, path) 358 | assert Enum.count(project.sources) == 1 359 | end 360 | end 361 | 362 | describe "from_sources/1" do 363 | test "creates a project" do 364 | assert {:ok, 365 | %Rewrite{ 366 | extensions: %{"default" => Source, ".ex" => Source.Ex, ".exs" => Source.Ex}, 367 | sources: %{ 368 | "b.txt" => %Source{ 369 | from: :string, 370 | path: "b.txt", 371 | content: "b", 372 | hash: 103_569_618, 373 | owner: Rewrite, 374 | history: [], 375 | issues: [], 376 | private: %{}, 377 | timestamp: timestamp 378 | } 379 | } 380 | }} = 381 | Rewrite.from_sources([ 382 | Source.from_string("b", path: "b.txt") 383 | ]) 384 | 385 | assert_in_delta timestamp, DateTime.utc_now() |> DateTime.to_unix(), 1 386 | end 387 | 388 | test "returns an error if path is missing" do 389 | a = Source.from_string(":a", path: "a.exs") 390 | b = Source.from_string(":b") 391 | 392 | assert {:error, error} = Rewrite.from_sources([a, b]) 393 | 394 | assert error == %Error{ 395 | reason: :invalid_sources, 396 | duplicated_paths: [], 397 | missing_paths: [b] 398 | } 399 | 400 | assert Error.message(error) == "invalid sources" 401 | end 402 | 403 | test "returns an error if paths are duplicated" do 404 | a = Source.from_string(":a", path: "a.exs") 405 | b = Source.from_string(":b", path: "a.exs") 406 | 407 | assert {:error, error} = Rewrite.from_sources([a, b]) 408 | 409 | assert error == %Error{ 410 | reason: :invalid_sources, 411 | duplicated_paths: [b], 412 | missing_paths: [] 413 | } 414 | 415 | assert Error.message(error) == "invalid sources" 416 | end 417 | end 418 | 419 | describe "from_sources!/1" do 420 | test "creates a project" do 421 | assert %Rewrite{ 422 | extensions: %{ 423 | "default" => Source, 424 | ".ex" => Source.Ex, 425 | ".exs" => Source.Ex 426 | }, 427 | sources: %{ 428 | "b.txt" => %Source{ 429 | from: :string, 430 | path: "b.txt", 431 | content: "b", 432 | hash: 103_569_618, 433 | owner: Rewrite, 434 | history: [], 435 | issues: [], 436 | timestamp: timestamp, 437 | private: %{} 438 | } 439 | } 440 | } = 441 | Rewrite.from_sources!([ 442 | Source.from_string("b", path: "b.txt") 443 | ]) 444 | 445 | assert_in_delta timestamp, DateTime.utc_now() |> DateTime.to_unix(), 1 446 | end 447 | 448 | test "raises an error if path is missing" do 449 | a = Source.from_string(":a", path: "a.exs") 450 | b = Source.from_string(":b") 451 | 452 | assert_raise Error, "invalid sources", fn -> 453 | Rewrite.from_sources!([a, b]) 454 | end 455 | end 456 | 457 | test "raises an error if paths are duplicated" do 458 | a = Source.from_string(":a", path: "a.exs") 459 | b = Source.from_string(":b", path: "a.exs") 460 | 461 | assert_raise Error, "invalid sources", fn -> 462 | Rewrite.from_sources!([a, b]) 463 | end 464 | end 465 | end 466 | 467 | describe "source/2" do 468 | test "returns the source struct for a path" do 469 | path = "test/fixtures/source/simple.ex" 470 | project = Rewrite.new!([path]) 471 | assert {:ok, %Source{}} = Rewrite.source(project, path) 472 | end 473 | 474 | test "raises an :error for an invalid path" do 475 | project = Rewrite.new!(["test/fixtures/source/simple.ex"]) 476 | path = "foo/bar.ex" 477 | 478 | assert Rewrite.source(project, path) == 479 | {:error, %Error{reason: :nosource, path: path}} 480 | end 481 | end 482 | 483 | describe "source!/2" do 484 | test "returns the source struct for a path" do 485 | path = "test/fixtures/source/simple.ex" 486 | project = Rewrite.new!([path]) 487 | assert %Source{} = Rewrite.source!(project, path) 488 | end 489 | 490 | test "raises an error for an invalid path" do 491 | project = Rewrite.new!(["test/fixtures/source/simple.ex"]) 492 | 493 | assert_raise Error, ~s|no source found for "foo/bar.ex"|, fn -> 494 | Rewrite.source!(project, "foo/bar.ex") 495 | end 496 | end 497 | end 498 | 499 | describe "map/2" do 500 | @describetag :tmp_dir 501 | 502 | test "maps a project without any changes", %{tmp_dir: tmp_dir} do 503 | foo = Path.join(tmp_dir, "foo.ex") 504 | bar = Path.join(tmp_dir, "bar.ex") 505 | baz = Path.join(tmp_dir, "baz.ex") 506 | File.write!(foo, ":foo") 507 | File.write!(bar, ":bar") 508 | File.write!(baz, ":baz") 509 | 510 | project = Rewrite.new!("#{tmp_dir}/**") 511 | 512 | {:ok, mapped} = Rewrite.map(project, fn source -> source end) 513 | 514 | assert project == mapped 515 | end 516 | 517 | test "maps a project", %{tmp_dir: tmp_dir} do 518 | foo = Path.join(tmp_dir, "foo.ex") 519 | bar = Path.join(tmp_dir, "bar.ex") 520 | baz = Path.join(tmp_dir, "baz.ex") 521 | File.write!(foo, ":foo") 522 | File.write!(bar, ":bar") 523 | File.write!(baz, ":baz") 524 | 525 | project = Rewrite.new!("#{tmp_dir}/**") 526 | 527 | {:ok, mapped} = 528 | Rewrite.map(project, fn source -> 529 | Source.update(source, :content, ":test") 530 | end) 531 | 532 | assert project != mapped 533 | end 534 | 535 | test "returns an error", %{tmp_dir: tmp_dir} do 536 | foo = Path.join(tmp_dir, "foo.ex") 537 | bar = Path.join(tmp_dir, "bar.ex") 538 | baz = Path.join(tmp_dir, "baz.ex") 539 | File.write!(foo, ":foo") 540 | File.write!(bar, ":bar") 541 | File.write!(baz, ":baz") 542 | 543 | project = Rewrite.new!("#{tmp_dir}/**") 544 | 545 | {:error, errors, mapped} = 546 | Rewrite.map(project, fn 547 | %Source{path: ^foo} = source -> Source.update(source, :content, ":test") 548 | %Source{path: ^bar} = source -> Source.update(source, :path, foo) 549 | %Source{path: ^baz} = source -> Source.update(source, :path, nil) 550 | end) 551 | 552 | assert project != mapped 553 | assert mapped |> Rewrite.source!(foo) |> Source.get(:content) == ":test" 554 | 555 | assert errors == [ 556 | %UpdateError{reason: :nopath, source: baz}, 557 | %UpdateError{reason: :overwrites, source: bar, path: foo} 558 | ] 559 | end 560 | end 561 | 562 | describe "map!/2" do 563 | @describetag :tmp_dir 564 | 565 | test "maps a project without any changes", %{tmp_dir: tmp_dir} do 566 | foo = Path.join(tmp_dir, "foo.ex") 567 | bar = Path.join(tmp_dir, "bar.ex") 568 | baz = Path.join(tmp_dir, "baz.ex") 569 | File.write!(foo, ":foo") 570 | File.write!(bar, ":bar") 571 | File.write!(baz, ":baz") 572 | 573 | project = Rewrite.new!("#{tmp_dir}/**") 574 | 575 | mapped = Rewrite.map!(project, fn source -> source end) 576 | 577 | assert project == mapped 578 | end 579 | 580 | test "maps a project", %{tmp_dir: tmp_dir} do 581 | foo = Path.join(tmp_dir, "foo.ex") 582 | bar = Path.join(tmp_dir, "bar.ex") 583 | baz = Path.join(tmp_dir, "baz.ex") 584 | File.write!(foo, ":foo") 585 | File.write!(bar, ":bar") 586 | File.write!(baz, ":baz") 587 | 588 | project = Rewrite.new!("#{tmp_dir}/**") 589 | 590 | mapped = 591 | Rewrite.map!(project, fn source -> 592 | Source.update(source, :content, ":test") 593 | end) 594 | 595 | assert project != mapped 596 | end 597 | 598 | test "raises an exception when overwrites", %{tmp_dir: tmp_dir} do 599 | foo = Path.join(tmp_dir, "foo.ex") 600 | bar = Path.join(tmp_dir, "bar.ex") 601 | File.write!(foo, ":foo") 602 | File.write!(bar, ":bar") 603 | 604 | project = Rewrite.new!("#{tmp_dir}/**") 605 | 606 | message = ~s|can't update source "#{bar}": updated source overwrites "#{foo}"| 607 | 608 | assert_raise UpdateError, message, fn -> 609 | Rewrite.map!(project, fn source -> 610 | Source.update(source, :path, foo) 611 | end) 612 | end 613 | end 614 | 615 | test "raises an exception when path is missing", %{tmp_dir: tmp_dir} do 616 | foo = Path.join(tmp_dir, "foo.ex") 617 | File.write!(foo, ":foo") 618 | 619 | project = Rewrite.new!("#{tmp_dir}/**") 620 | 621 | message = ~s|can't update source "#{foo}": no path in updated source| 622 | 623 | assert_raise UpdateError, message, fn -> 624 | Rewrite.map!(project, fn source -> 625 | Source.update(source, :path, nil) 626 | end) 627 | end 628 | end 629 | 630 | test "raises RuntimeError" do 631 | {:ok, project} = Rewrite.from_sources([Source.from_string(":a", path: "a.exs")]) 632 | 633 | message = "expected %Source{} from anonymous function given to Rewrite.update/3, got: :foo" 634 | 635 | assert_raise RuntimeError, message, fn -> 636 | Rewrite.map!(project, fn _source -> :foo end) 637 | end 638 | end 639 | end 640 | 641 | describe "Enum.map/2" do 642 | test "maps a project without any changes" do 643 | inputs = ["test/fixtures/source/simple.ex"] 644 | 645 | project = Rewrite.new!(inputs) 646 | 647 | mapped = Enum.map(project, fn source -> source end) 648 | 649 | assert is_list(mapped) 650 | assert Enum.sort(mapped) == project.sources |> Map.values() |> Enum.sort() 651 | end 652 | 653 | test "maps a project" do 654 | inputs = ["test/fixtures/source/simple.ex"] 655 | 656 | project = Rewrite.new!(inputs) 657 | 658 | mapped = 659 | Enum.map(project, fn source -> 660 | Source.update(source, :path, "new/path/simple.ex", by: :test) 661 | end) 662 | 663 | assert is_list(mapped) 664 | assert Rewrite.from_sources(mapped) != {:ok, project} 665 | end 666 | end 667 | 668 | describe "update/2" do 669 | test "updates a source" do 670 | a = Source.from_string(":a", path: "a.exs") 671 | b = Source.from_string(":b", path: "a.exs") 672 | {:ok, project} = Rewrite.from_sources([a]) 673 | 674 | {:ok, project} = Rewrite.update(project, b) 675 | 676 | assert project.sources == %{"a.exs" => b} 677 | end 678 | 679 | test "returns an error when path changed" do 680 | a = Source.from_string(":a", path: "a.exs") 681 | b = Source.from_string(":b", path: "b.exs") 682 | {:ok, project} = Rewrite.from_sources([a]) 683 | 684 | assert Rewrite.update(project, b) == 685 | {:error, %Error{reason: :nosource, path: "b.exs"}} 686 | end 687 | 688 | test "returns an error when path is nil" do 689 | a = Source.from_string(":a", path: "a.exs") 690 | b = Source.from_string(":b") 691 | {:ok, project} = Rewrite.from_sources([a]) 692 | 693 | assert Rewrite.update(project, b) == {:error, %Error{reason: :nopath}} 694 | end 695 | end 696 | 697 | describe "update!/2" do 698 | test "raises an exception when source not in project" do 699 | project = Rewrite.new() 700 | source = Source.from_string(":a", path: "a.exs") 701 | 702 | message = ~s|no source found for "a.exs"| 703 | 704 | assert_raise Error, message, fn -> 705 | Rewrite.update!(project, source) 706 | end 707 | end 708 | 709 | test "raises an exception when source path is nil" do 710 | project = Rewrite.new() 711 | source = Source.from_string(":a") 712 | 713 | message = "no path found" 714 | 715 | assert_raise Error, message, fn -> 716 | Rewrite.update!(project, source) 717 | end 718 | end 719 | end 720 | 721 | describe "update/3" do 722 | test "updates a source" do 723 | a = Source.from_string(":a", path: "a.exs") 724 | b = Source.from_string(":b", path: "b.exs") 725 | {:ok, project} = Rewrite.from_sources([a]) 726 | 727 | assert {:ok, project} = Rewrite.update(project, a.path, b) 728 | 729 | assert project.sources == %{"b.exs" => b} 730 | end 731 | 732 | test "updates a source with a function" do 733 | a = Source.from_string(":a", path: "a.exs") 734 | b = Source.from_string(":b", path: "b.exs") 735 | {:ok, project} = Rewrite.from_sources([a]) 736 | 737 | assert {:ok, project} = Rewrite.update(project, a.path, fn _ -> b end) 738 | 739 | assert project.sources == %{"b.exs" => b} 740 | end 741 | 742 | test "returns an error when source not in project" do 743 | project = Rewrite.new() 744 | a = Source.from_string(":a", path: "a.exs") 745 | 746 | assert Rewrite.update(project, a.path, a) == 747 | {:error, %Error{reason: :nosource, path: a.path}} 748 | end 749 | 750 | test "returns an error when path is nil" do 751 | a = Source.from_string(":a", path: "a.exs") 752 | b = Source.from_string(":b") 753 | {:ok, project} = Rewrite.from_sources([a]) 754 | 755 | assert Rewrite.update(project, a.path, b) == 756 | {:error, %UpdateError{reason: :nopath, source: a.path}} 757 | end 758 | 759 | test "returns an error when another source would be overwritten" do 760 | a = Source.from_string(":a", path: "a.exs") 761 | b = Source.from_string(":b", path: "b.exs") 762 | c = Source.from_string(":c", path: "b.exs") 763 | {:ok, project} = Rewrite.from_sources([a, b]) 764 | 765 | assert Rewrite.update(project, a.path, c) == 766 | {:error, %UpdateError{reason: :overwrites, source: a.path, path: c.path}} 767 | end 768 | 769 | test "raises an error when function does not returns a source" do 770 | a = Source.from_string(":a", path: "a.exs") 771 | {:ok, project} = Rewrite.from_sources([a]) 772 | message = "expected %Source{} from anonymous function given to Rewrite.update/3, got: nil" 773 | 774 | assert_raise RuntimeError, message, fn -> 775 | Rewrite.update(project, a.path, fn _ -> nil end) 776 | end 777 | end 778 | end 779 | 780 | describe "update!/3" do 781 | test "updates a source" do 782 | a = Source.from_string(":a", path: "a.exs") 783 | b = Source.from_string(":b", path: "b.exs") 784 | {:ok, project} = Rewrite.from_sources([a]) 785 | 786 | assert project = Rewrite.update!(project, a.path, b) 787 | 788 | assert project.sources == %{"b.exs" => b} 789 | end 790 | 791 | test "updates a source with a function" do 792 | a = Source.from_string(":a", path: "a.exs") 793 | b = Source.from_string(":b", path: "b.exs") 794 | {:ok, project} = Rewrite.from_sources([a]) 795 | 796 | assert project = Rewrite.update!(project, a.path, fn _ -> b end) 797 | 798 | assert project.sources == %{"b.exs" => b} 799 | end 800 | 801 | test "returns an error when source not in project" do 802 | project = Rewrite.new() 803 | a = Source.from_string(":a", path: "a.exs") 804 | 805 | message = ~s'no source found for "a.exs"' 806 | 807 | assert_raise Error, message, fn -> 808 | Rewrite.update!(project, a.path, a) 809 | end 810 | end 811 | 812 | test "returns an error when path is nil" do 813 | a = Source.from_string(":a", path: "a.exs") 814 | b = Source.from_string(":b") 815 | {:ok, project} = Rewrite.from_sources([a]) 816 | 817 | message = ~s|can't update source "a.exs": no path in updated source| 818 | 819 | assert_raise UpdateError, message, fn -> 820 | Rewrite.update!(project, a.path, b) 821 | end 822 | end 823 | 824 | test "returns an error when another source would be overwritten" do 825 | a = Source.from_string(":a", path: "a.exs") 826 | b = Source.from_string(":b", path: "b.exs") 827 | c = Source.from_string(":c", path: "b.exs") 828 | {:ok, project} = Rewrite.from_sources([a, b]) 829 | 830 | message = ~s|can't update source "a.exs": updated source overwrites "b.exs"| 831 | 832 | assert_raise(UpdateError, message, fn -> 833 | Rewrite.update!(project, a.path, c) 834 | end) 835 | end 836 | 837 | test "raises an error when function does not returns a source" do 838 | a = Source.from_string(":a", path: "a.exs") 839 | {:ok, project} = Rewrite.from_sources([a]) 840 | message = "expected %Source{} from anonymous function given to Rewrite.update/3, got: nil" 841 | 842 | assert_raise RuntimeError, message, fn -> 843 | Rewrite.update!(project, a.path, fn _ -> nil end) 844 | end 845 | end 846 | end 847 | 848 | describe "update_source!/4" do 849 | test "raises an error" do 850 | project = Rewrite.new() 851 | message = ~s|no source found for "some.txt"| 852 | 853 | assert_raise Error, message, fn -> 854 | Rewrite.update_source!(project, "some.txt", :content, &String.upcase/1) 855 | end 856 | end 857 | end 858 | 859 | describe "count/2" do 860 | test "counts by the given type" do 861 | {:ok, project} = 862 | Rewrite.from_sources([ 863 | Source.from_string(":a", path: "a.ex"), 864 | Source.from_string(":b", path: "b.exs") 865 | ]) 866 | 867 | assert Rewrite.count(project, ".ex") == 1 868 | assert Rewrite.count(project, ".exs") == 1 869 | end 870 | end 871 | 872 | describe "sources/1" do 873 | test "returns all sources" do 874 | {:ok, project} = 875 | Rewrite.from_sources([ 876 | Source.from_string(":c", path: "c.exs"), 877 | Source.from_string(":a", path: "a.exs"), 878 | Source.from_string(":b", path: "b.exs") 879 | ]) 880 | 881 | assert project 882 | |> Rewrite.sources() 883 | |> Enum.map(fn source -> source.path end) == ["a.exs", "b.exs", "c.exs"] 884 | end 885 | end 886 | 887 | describe "write!/2" do 888 | @describetag :tmp_dir 889 | 890 | test "writes a source to disk", context do 891 | in_tmp context do 892 | File.write!("foo.ex", ":foo") 893 | 894 | source = Source.read!("foo.ex") 895 | {:ok, project} = Rewrite.from_sources([source]) 896 | source = Source.update(source, :content, ":foofoo\n") 897 | 898 | assert project = Rewrite.write!(project, source) 899 | assert source = Rewrite.source!(project, "foo.ex") 900 | assert Source.get(source, :content) == File.read!("foo.ex") 901 | assert Source.updated?(source) == false 902 | end 903 | end 904 | 905 | test "writes a source to disk by path", context do 906 | in_tmp context do 907 | File.write!("foo.ex", ":foo") 908 | 909 | source = Source.read!("foo.ex") 910 | {:ok, project} = Rewrite.from_sources([source]) 911 | source = Source.update(source, :content, ":foofoo\n", by: :test) 912 | project = Rewrite.update!(project, source) 913 | 914 | assert project = Rewrite.write!(project, "foo.ex") 915 | assert source = Rewrite.source!(project, "foo.ex") 916 | assert Source.get(source, :content) == File.read!("foo.ex") 917 | assert Source.updated?(source) == false 918 | end 919 | end 920 | 921 | test "raises an error for missing source" do 922 | project = Rewrite.new() 923 | 924 | assert_raise Error, ~s|no source found for "source.ex"|, fn -> 925 | Rewrite.write!(project, "source.ex") 926 | end 927 | end 928 | end 929 | 930 | describe "write/2" do 931 | @describetag :tmp_dir 932 | 933 | test "returns an error when the file was changed", context do 934 | in_tmp context do 935 | File.write!("foo.ex", ":foo") 936 | 937 | source = Source.read!("foo.ex") 938 | {:ok, project} = Rewrite.from_sources([source]) 939 | source = Source.update(source, :content, ":foofoo\n", by: :test) 940 | 941 | File.write!("foo.ex", ":bar") 942 | 943 | assert Rewrite.write(project, source) == 944 | {:error, %SourceError{reason: :changed, path: "foo.ex", action: :write}} 945 | end 946 | end 947 | 948 | test "returns an error for missing source" do 949 | project = Rewrite.new() 950 | 951 | assert Rewrite.write(project, "source.ex") == 952 | {:error, %Error{reason: :nosource, path: "source.ex"}} 953 | end 954 | end 955 | 956 | describe "write_all/2" do 957 | @describetag :tmp_dir 958 | 959 | test "writes sources to disk", %{tmp_dir: tmp_dir} do 960 | path = Path.join(tmp_dir, "test.ex") 961 | 962 | {:ok, project} = 963 | Rewrite.from_sources([ 964 | ":foo" |> Source.from_string(path: path) |> Source.update(:content, ":test", by: Test) 965 | ]) 966 | 967 | assert {:ok, project} = Rewrite.write_all(project) 968 | assert File.read!(path) == ":test\n" 969 | assert project |> Rewrite.source!(path) |> Source.updated?() == false 970 | end 971 | 972 | test "creates dir", %{tmp_dir: tmp_dir} do 973 | path = Path.join(tmp_dir, "new_dir/test.ex") 974 | 975 | {:ok, project} = 976 | Rewrite.from_sources([ 977 | ":foo\n" |> Source.from_string() |> Source.update(:path, path, by: Test) 978 | ]) 979 | 980 | assert {:ok, _project} = Rewrite.write_all(project) 981 | assert File.read!(path) == ":foo\n" 982 | end 983 | 984 | test "removes old file", %{tmp_dir: tmp_dir} do 985 | foo = Path.join(tmp_dir, "foo.ex") 986 | bar = Path.join(tmp_dir, "bar.ex") 987 | File.write!(foo, ":foo") 988 | 989 | {:ok, project} = 990 | Rewrite.from_sources([ 991 | foo |> Source.read!() |> Source.update(:path, bar, by: :test) 992 | ]) 993 | 994 | assert {:ok, _project} = Rewrite.write_all(project) 995 | assert File.exists?(foo) == false 996 | assert File.read!(bar) == ":foo\n" 997 | end 998 | 999 | test "excludes files", %{tmp_dir: tmp_dir} do 1000 | foo = Path.join(tmp_dir, "foo.ex") 1001 | bar = Path.join(tmp_dir, "bar.ex") 1002 | File.write!(foo, ":foo") 1003 | 1004 | {:ok, project} = 1005 | Rewrite.from_sources([ 1006 | foo |> Source.read!() |> Source.update(:path, bar, by: :test) 1007 | ]) 1008 | 1009 | assert {:ok, _project} = Rewrite.write_all(project, exclude: [bar]) 1010 | assert File.exists?(foo) 1011 | end 1012 | 1013 | test "returns {:error, errors, project}", %{tmp_dir: tmp_dir} do 1014 | path = Path.join(tmp_dir, "foo.ex") 1015 | File.write!(path, ":bar") 1016 | System.cmd("chmod", ["-w", path]) 1017 | 1018 | {:ok, project} = 1019 | Rewrite.from_sources([ 1020 | path |> Source.read!() |> Source.update(:content, ":new", by: :test) 1021 | ]) 1022 | 1023 | assert {:error, [error], _project} = Rewrite.write_all(project) 1024 | assert error == %SourceError{reason: :eacces, path: path, action: :write} 1025 | end 1026 | 1027 | test "does nothing without updates", %{tmp_dir: tmp_dir} do 1028 | path = Path.join(tmp_dir, "foo.ex") 1029 | File.write!(path, ":bar") 1030 | 1031 | project = Rewrite.new!(path) 1032 | 1033 | assert {:ok, saved} = Rewrite.write_all(project) 1034 | assert project == saved 1035 | end 1036 | 1037 | test "returns {:error, errors, project} for changed files", %{tmp_dir: tmp_dir} do 1038 | foo = Path.join(tmp_dir, "foo.ex") 1039 | bar = Path.join(tmp_dir, "bar.ex") 1040 | File.write!(foo, ":foo") 1041 | File.write!(bar, ":bar") 1042 | 1043 | {:ok, project} = 1044 | Rewrite.from_sources([ 1045 | foo |> Source.read!() |> Source.update(:content, ":up", by: :test), 1046 | bar |> Source.read!() |> Source.update(:content, ":barbar", by: :test) 1047 | ]) 1048 | 1049 | File.write!(foo, ":foofoo") 1050 | 1051 | assert {:error, errors, project} = Rewrite.write_all(project) 1052 | 1053 | assert errors == [%SourceError{reason: :changed, path: foo, action: :write}] 1054 | assert File.read!(bar) == ":barbar\n" 1055 | assert project |> Rewrite.source!(foo) |> Source.updated?() == true 1056 | assert project |> Rewrite.source!(bar) |> Source.updated?() == false 1057 | 1058 | assert {:ok, _project} = Rewrite.write_all(project, force: true) 1059 | assert File.read!(foo) == ":up\n" 1060 | end 1061 | end 1062 | 1063 | describe "issue?/1" do 1064 | test "returns false" do 1065 | {:ok, project} = 1066 | Rewrite.from_sources([ 1067 | Source.from_string(":a", path: "a.exs"), 1068 | Source.from_string(":b", path: "b.exs"), 1069 | Source.from_string(":c", path: "c.exs") 1070 | ]) 1071 | 1072 | assert Rewrite.issues?(project) == false 1073 | end 1074 | 1075 | test "returns true" do 1076 | {:ok, project} = 1077 | Rewrite.from_sources([ 1078 | Source.from_string(":a", path: "a.exs"), 1079 | Source.from_string(":b", path: "b.exs"), 1080 | ":c" |> Source.from_string(path: "c.exs") |> Source.add_issue(%{foo: 42}) 1081 | ]) 1082 | 1083 | assert Rewrite.issues?(project) == true 1084 | end 1085 | end 1086 | 1087 | describe "Enum" do 1088 | test "count/1" do 1089 | {:ok, project} = 1090 | Rewrite.from_sources([ 1091 | Source.from_string(":a", path: "a.exs"), 1092 | Source.from_string(":b", path: "b.exs"), 1093 | Source.from_string(":c", path: "c.exs") 1094 | ]) 1095 | 1096 | assert Enum.count(project) == 3 1097 | end 1098 | 1099 | test "slice/3" do 1100 | a = Source.from_string(":a", path: "a.exs") 1101 | b = Source.from_string(":b", path: "b.exs") 1102 | c = Source.from_string(":c", path: "c.exs") 1103 | {:ok, project} = Rewrite.from_sources([a, b, c]) 1104 | 1105 | assert project |> Enum.slice(1, 2) |> Enum.map(fn source -> source.path end) == 1106 | ["b.exs", "c.exs"] 1107 | 1108 | assert project |> Enum.slice(1, 1) |> Enum.map(fn source -> source.path end) == 1109 | ["b.exs"] 1110 | 1111 | assert Enum.slice(project, 1, 0) == [] 1112 | end 1113 | 1114 | test "member?/1 returns true" do 1115 | project = Rewrite.new() 1116 | project = Rewrite.new_source!(project, "a.ex", ":a") 1117 | source = Rewrite.source!(project, "a.ex") 1118 | 1119 | assert Enum.member?(project, source) == true 1120 | end 1121 | 1122 | test "member?/1 returns false" do 1123 | a = Source.from_string(":a", path: "a.exs") 1124 | b = Source.from_string(":b", path: "b.exs") 1125 | 1126 | {:ok, project} = Rewrite.from_sources([a]) 1127 | 1128 | assert Enum.member?(project, b) == false 1129 | assert Enum.member?(project, :a) == false 1130 | end 1131 | end 1132 | 1133 | describe "dot_formatter/1/2" do 1134 | test "returns a default dot formatter" do 1135 | project = Rewrite.new() 1136 | assert Rewrite.dot_formatter(project) == DotFormatter.default() 1137 | end 1138 | 1139 | test "returns the set dot formatter" do 1140 | {:ok, dot_formatter} = DotFormatter.read() 1141 | project = Rewrite.new() 1142 | 1143 | assert dot_formatter != DotFormatter.default() 1144 | assert project = Rewrite.dot_formatter(project, dot_formatter) 1145 | assert Rewrite.dot_formatter(project) == dot_formatter 1146 | end 1147 | end 1148 | 1149 | describe "new_source/4" do 1150 | test "creates a source" do 1151 | rewrite = Rewrite.new() 1152 | assert {:ok, rewrite} = Rewrite.new_source(rewrite, "test.ex", "test") 1153 | assert {:ok, source} = Rewrite.source(rewrite, "test.ex") 1154 | assert is_struct(source.filetype, Source.Ex) 1155 | end 1156 | 1157 | test "return an error tuple when the source already exists" do 1158 | rewrite = Rewrite.new() 1159 | assert {:ok, rewrite} = Rewrite.new_source(rewrite, "test.ex", "test") 1160 | assert {:error, _error} = Rewrite.new_source(rewrite, "test.ex", "test") 1161 | end 1162 | 1163 | test "creates a source with opts" do 1164 | rewrite = Rewrite.new() 1165 | 1166 | assert {:ok, rewrite} = 1167 | Rewrite.new_source(rewrite, "test.ex", "test", owner: MyApp, resync_quoted: false) 1168 | 1169 | assert {:ok, source} = Rewrite.source(rewrite, "test.ex") 1170 | assert source.owner == MyApp 1171 | assert source.filetype.opts == [resync_quoted: false] 1172 | end 1173 | end 1174 | 1175 | describe "new_source!/4" do 1176 | test "raises an error tuple when the source already exists" do 1177 | rewrite = Rewrite.new() 1178 | assert rewrite = Rewrite.new_source!(rewrite, "test.ex", "test") 1179 | 1180 | message = "overwrites \"test.ex\"" 1181 | 1182 | assert_raise Error, message, fn -> 1183 | Rewrite.new_source!(rewrite, "test.ex", "test") 1184 | end 1185 | end 1186 | end 1187 | 1188 | describe "create_source/4" do 1189 | test "creates a source" do 1190 | rewrite = Rewrite.new() 1191 | assert source = Rewrite.create_source(rewrite, "test.ex", "test") 1192 | assert is_struct(source.filetype, Source.Ex) 1193 | end 1194 | 1195 | test "creates a default source" do 1196 | rewrite = Rewrite.new() 1197 | Rewrite.create_source(rewrite, nil, "test") 1198 | assert source = Rewrite.create_source(rewrite, nil, "test") 1199 | refute is_struct(source.filetype, Source.Ex) 1200 | end 1201 | end 1202 | 1203 | describe "format/2" do 1204 | @describetag :tmp_dir 1205 | 1206 | test "formats the rewrite project", context do 1207 | in_tmp context do 1208 | write!( 1209 | ".formatter.exs": """ 1210 | [ 1211 | inputs: ["**/*.{ex,.exs}"], 1212 | locals_without_parens: [foo: 1] 1213 | ] 1214 | """, 1215 | "a.ex": """ 1216 | foo bar baz 1217 | """ 1218 | ) 1219 | 1220 | project = Rewrite.new!("**/*") 1221 | 1222 | assert {:ok, formatted} = Rewrite.format(project) 1223 | assert read!(formatted, "a.ex") == "foo(bar(baz))\n" 1224 | 1225 | {:ok, dot_formatter} = DotFormatter.read() 1226 | assert project = Rewrite.dot_formatter(project, dot_formatter) 1227 | assert {:ok, formatted} = Rewrite.format(project) 1228 | assert read!(formatted, "a.ex") == "foo bar(baz)\n" 1229 | 1230 | project = Rewrite.new!("**/*", dot_formatter: dot_formatter) 1231 | 1232 | assert {:ok, formatted} = Rewrite.format(project) 1233 | assert read!(formatted, "a.ex") == "foo bar(baz)\n" 1234 | end 1235 | end 1236 | end 1237 | 1238 | describe "format!/2" do 1239 | @describetag :tmp_dir 1240 | 1241 | test "raises an error", context do 1242 | in_tmp context do 1243 | write!( 1244 | ".formatter.exs": """ 1245 | [ 1246 | inputs: ["**/*.{ex,.exs}"], 1247 | locals_without_parens: [foo: 1] 1248 | ] 1249 | """ 1250 | ) 1251 | 1252 | project = Rewrite.new!("**/*") 1253 | 1254 | message = "Expected :remove_plugins to be a list of modules, got: :bar" 1255 | 1256 | assert_raise DotFormatterError, message, fn -> 1257 | Rewrite.format!(project, remove_plugins: :bar) == :error 1258 | end 1259 | 1260 | message = "Expected :replace_plugins to be a list of tuples, got: :bar" 1261 | 1262 | assert_raise DotFormatterError, message, fn -> 1263 | Rewrite.format!(project, replace_plugins: :bar) == :error 1264 | end 1265 | end 1266 | end 1267 | end 1268 | 1269 | describe "hooks" do 1270 | @describetag :tmp_dir 1271 | 1272 | test "are called", context do 1273 | in_tmp context do 1274 | File.write!("README.md", "readme") 1275 | 1276 | "**/*" 1277 | |> Rewrite.new!(hooks: [InspectHook]) 1278 | |> Rewrite.new_source!("foo.ex", "foo") 1279 | |> Rewrite.put!(Source.from_string("bar", path: "bar.ex")) 1280 | |> Rewrite.update!("foo.ex", fn source -> Source.update(source, :content, "foofoo") end) 1281 | |> Rewrite.update!("bar.ex", Source.from_string("barbar", path: "bar.ex")) 1282 | 1283 | assert File.read!("inspect.txt") == """ 1284 | :new - #Rewrite<0 source(s)> 1285 | {:added, ["README.md", "inspect.txt"]} - #Rewrite<2 source(s)> 1286 | {:added, ["foo.ex"]} - #Rewrite<3 source(s)> 1287 | {:added, ["bar.ex"]} - #Rewrite<4 source(s)> 1288 | {:updated, "foo.ex"} - #Rewrite<4 source(s)> 1289 | {:updated, "bar.ex"} - #Rewrite<4 source(s)> 1290 | """ 1291 | end 1292 | end 1293 | 1294 | test "are called by from_sources", context do 1295 | in_tmp context do 1296 | source = Source.from_string("foo", path: "foo.ex") 1297 | Rewrite.from_sources([source], hooks: [InspectHook]) 1298 | 1299 | assert File.read!("inspect.txt") == """ 1300 | :new - #Rewrite<0 source(s)> 1301 | {:added, ["foo.ex"]} - #Rewrite<1 source(s)> 1302 | """ 1303 | end 1304 | end 1305 | 1306 | test "are called for successfull formatting", context do 1307 | in_tmp context do 1308 | write!( 1309 | "a.ex": """ 1310 | x = y 1311 | """, 1312 | "b.ex": """ 1313 | y = x 1314 | """ 1315 | ) 1316 | 1317 | project = Rewrite.new!("**/*", hooks: [InspectHook]) 1318 | project = Rewrite.format!(project) 1319 | 1320 | expected = """ 1321 | :new - #Rewrite<0 source(s)> 1322 | {:added, ["a.ex", "b.ex", "inspect.txt"]} - #Rewrite<3 source(s)> 1323 | {:updated, "a.ex"} - #Rewrite<3 source(s)> 1324 | {:updated, "b.ex"} - #Rewrite<3 source(s)> 1325 | """ 1326 | 1327 | assert File.read!("inspect.txt") == expected 1328 | 1329 | Rewrite.format!(project) 1330 | 1331 | assert File.read!("inspect.txt") == expected 1332 | end 1333 | end 1334 | 1335 | test "raises an error", context do 1336 | defmodule RaiseHook do 1337 | def handle(_action, _project), do: :foo 1338 | end 1339 | 1340 | message = "unexpected response from hook, got: :foo" 1341 | 1342 | assert_raise Error, message, fn -> 1343 | Rewrite.new(hooks: [RaiseHook]) 1344 | end 1345 | end 1346 | end 1347 | 1348 | test "inspect" do 1349 | rewrite = Rewrite.new() 1350 | assert inspect(rewrite) == "#Rewrite<0 source(s)>" 1351 | end 1352 | end 1353 | -------------------------------------------------------------------------------- /test/support/alt_ex_plugin.ex: -------------------------------------------------------------------------------- 1 | defmodule AltExPlugin do 2 | @moduledoc """ 3 | An alternative Elixir formatter plugin. 4 | 5 | The formatter sets `:force_do_end_blocks` to true by default. 6 | """ 7 | 8 | import ExUnit.Assertions 9 | 10 | @behaviour Mix.Tasks.Format 11 | 12 | @impl true 13 | def features(opts) do 14 | assert opts[:from_formatter_exs] || opts[:plugin_option] == :yes 15 | [extensions: ~w(.ex .exs), sigils: []] 16 | end 17 | 18 | @impl true 19 | def format(input, opts) do 20 | formatted = 21 | input 22 | |> Code.string_to_quoted!() 23 | |> to_algebra(opts) 24 | |> Inspect.Algebra.format(:infinity) 25 | |> IO.iodata_to_binary() 26 | 27 | formatted <> "\n" 28 | end 29 | 30 | def to_algebra(quoted, opts) do 31 | assert opts[:from_formatter_exs] || opts[:plugin_option] == :yes 32 | assert is_binary(opts[:file]) 33 | 34 | opts = Keyword.put(opts, :force_do_end_blocks, true) 35 | Code.quoted_to_algebra(quoted, opts) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/support/alt_ex_wrapper_plugin.ex: -------------------------------------------------------------------------------- 1 | defmodule AltExWrapperPlugin do 2 | @moduledoc """ 3 | A wrapper for `AltExPlugin`. 4 | """ 5 | 6 | import ExUnit.Assertions 7 | 8 | alias AltExPlugin 9 | @behaviour Rewrite.DotFormatter 10 | 11 | @impl true 12 | defdelegate features(opts), to: AltExPlugin 13 | 14 | @impl true 15 | defdelegate format(input, opts), to: AltExPlugin 16 | 17 | @impl true 18 | def quoted_to_algebra(input, opts) do 19 | assert opts[:wrapper] == :yes 20 | AltExPlugin.to_algebra(input, opts) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/support/extension_w_plugin.ex: -------------------------------------------------------------------------------- 1 | defmodule ExtensionWPlugin do 2 | @moduledoc false 3 | 4 | import ExUnit.Assertions 5 | 6 | @behaviour Mix.Tasks.Format 7 | 8 | @impl true 9 | def features(opts) do 10 | assert opts[:from_formatter_exs] == :yes 11 | [extensions: ~w(.w), sigils: [:W]] 12 | end 13 | 14 | @impl true 15 | def format(contents, opts) do 16 | assert opts[:from_formatter_exs] == :yes 17 | assert opts[:extension] == ".w" 18 | assert opts[:file] =~ ~r/a\.w$/ 19 | assert [W: sigil_fun] = opts[:sigils] 20 | assert is_function(sigil_fun, 2) 21 | contents |> String.split(~r/\s/) |> Enum.join("\n") 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/support/inspect_hook.ex: -------------------------------------------------------------------------------- 1 | defmodule InspectHook do 2 | @moduledoc false 3 | 4 | @behaviour Rewrite.Hook 5 | 6 | @file_name "inspect.txt" 7 | 8 | def handle(:new, project) do 9 | write(":new - #{inspect(project)}") 10 | 11 | :ok 12 | end 13 | 14 | def handle(action, project) do 15 | append("#{action |> sort() |> inspect()} - #{inspect(project)}") 16 | 17 | :ok 18 | end 19 | 20 | defp sort({action, value}) when is_list(value), do: {action, Enum.sort(value)} 21 | defp sort(action), do: action 22 | 23 | defp write(message) do 24 | File.write!(@file_name, message <> "\n") 25 | end 26 | 27 | defp append(message) do 28 | file = File.open!(@file_name, [:append]) 29 | IO.write(file, message <> "\n") 30 | File.close(file) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/support/new_line_to_dot_plugin.ex: -------------------------------------------------------------------------------- 1 | defmodule NewlineToDotPlugin do 2 | @moduledoc false 3 | 4 | import ExUnit.Assertions 5 | import GlobEx.Sigils 6 | 7 | @behaviour Mix.Tasks.Format 8 | 9 | @impl true 10 | def features(opts) do 11 | assert opts[:from_formatter_exs] == :yes 12 | [extensions: ~w(.w), sigils: [:W]] 13 | end 14 | 15 | @impl true 16 | def format(contents, opts) do 17 | assert opts[:from_formatter_exs] == :yes 18 | 19 | cond do 20 | opts[:extension] -> 21 | assert opts[:extension] == ".w" 22 | assert opts[:file] =~ ~r/a\.w$/ 23 | assert [W: sigil_fun] = opts[:sigils] 24 | assert is_function(sigil_fun, 2) 25 | 26 | opts[:sigil] -> 27 | assert opts[:sigil] == :W 28 | assert opts[:inputs] == [~g|a.ex|d] 29 | assert opts[:modifiers] == ~c"abc" 30 | 31 | true -> 32 | flunk("Plugin not loading in correctly.") 33 | end 34 | 35 | contents |> String.replace("\n", ".") 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/support/plts/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hrzndhrn/rewrite/b263f613b32df2883bcfb3bcb743033c042da54f/test/support/plts/.keep -------------------------------------------------------------------------------- /test/support/rewrite_case.ex: -------------------------------------------------------------------------------- 1 | defmodule TestHelpers do 2 | @moduledoc false 3 | 4 | @time 1_723_308_800 5 | alias Rewrite.Source 6 | 7 | defmacro in_tmp(context, do: block) do 8 | quote do 9 | File.cd!(unquote(context).tmp_dir, fn -> 10 | Mix.Project.pop() 11 | 12 | if unquote(context)[:project] do 13 | Mix.Project.push(format_with_deps_app()) 14 | end 15 | 16 | unquote(block) 17 | end) 18 | end 19 | end 20 | 21 | def test_time, do: @time 22 | 23 | def format_with_deps_app do 24 | nr = :erlang.unique_integer([:positive]) 25 | 26 | {{:module, module, _bin, _meta}, _binding} = 27 | Code.eval_string(""" 28 | defmodule FormatWithDepsApp#{nr} do 29 | def project do 30 | [ 31 | app: :format_with_deps_#{nr}, 32 | version: "0.1.0", 33 | deps: [{:my_dep, "0.1.0", path: "deps/my_dep"}] 34 | ] 35 | end 36 | end 37 | """) 38 | 39 | module 40 | end 41 | 42 | def write!(time \\ @time, files) when is_list(files) do 43 | Enum.map(files, fn {file, content} -> file |> to_string() |> write!(content, time) end) 44 | end 45 | 46 | defp write!(path, content, time) do 47 | dir = Path.dirname(path) 48 | unless dir == ".", do: path |> Path.dirname() |> File.mkdir_p!() 49 | File.write!(path, content) 50 | if is_integer(time), do: File.touch!(path, @time) 51 | path 52 | end 53 | 54 | def read!(path), do: File.read!(path) 55 | 56 | def read!(rewrite, path) do 57 | rewrite |> Rewrite.source!(path) |> Source.get(:content) 58 | end 59 | 60 | def touched?(path, time), do: File.stat!(path, time: :posix).mtime > time 61 | 62 | def touched?(rewrite, path, time) do 63 | source = Rewrite.source!(rewrite, path) 64 | source.timestamp > time 65 | end 66 | 67 | def now, do: DateTime.utc_now() |> DateTime.to_unix() 68 | end 69 | 70 | defmodule RewriteCase do 71 | @moduledoc false 72 | 73 | use ExUnit.CaseTemplate, async: false 74 | 75 | using do 76 | quote do 77 | import TestHelpers 78 | require TestHelpers 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /test/support/sigil_w_plugin.ex: -------------------------------------------------------------------------------- 1 | defmodule SigilWPlugin do 2 | @moduledoc false 3 | 4 | import ExUnit.Assertions 5 | 6 | @behaviour Mix.Tasks.Format 7 | 8 | @impl true 9 | def features(opts) do 10 | assert opts[:from_formatter_exs] == :yes 11 | [sigils: [:W]] 12 | end 13 | 14 | @impl true 15 | def format(contents, opts) do 16 | assert opts[:from_formatter_exs] == :yes 17 | assert opts[:sigil] == :W 18 | assert opts[:modifiers] == ~c"abc" 19 | assert opts[:line] == 2 20 | assert opts[:file] =~ ~r/a\.ex$/ 21 | contents |> String.split(~r/\s/) |> Enum.join("\n") 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | "test/support/**/*.ex" 2 | |> Path.wildcard() 3 | |> Enum.each(&Code.compile_file/1) 4 | 5 | ExUnit.start() 6 | -------------------------------------------------------------------------------- /tmp/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hrzndhrn/rewrite/b263f613b32df2883bcfb3bcb743033c042da54f/tmp/.keep --------------------------------------------------------------------------------