diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..5ace4600a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08aa879b9..1b0a697f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,35 +16,30 @@ jobs: fail-fast: false matrix: include: - - elixir: 1.10.x - otp: 22.3.4.20 - tests_may_fail: false - check_unused_deps: true - - elixir: 1.11.x - otp: 23.3.4.4 - tests_may_fail: false - check_unused_deps: true - elixir: 1.12.x - otp: 23.3.4.4 - tests_may_fail: false - check_unused_deps: true + otp: 22.x + - elixir: 1.12.x + otp: 23.x + - elixir: 1.12.x + otp: 24.x + - elixir: 1.13.x + otp: 22.x + - elixir: 1.13.x + otp: 23.x - elixir: 1.13.x - otp: 22.3.4.20 - tests_may_fail: false - check_unused_deps: true - warnings_as_errors: false + otp: 24.x - elixir: 1.13.x - otp: 24.1.x - tests_may_fail: false - # Needs to be false until https://github.com/elixir-lsp/elixir-ls/issues/642 is fixed - warnings_as_errors: false - check_formatted: true - check_unused_deps: true - run_dialyzer: true + otp: 25.x + - elixir: 1.14.x + otp: 23.x + - elixir: 1.14.x + otp: 24.x + - elixir: 1.14.x + otp: 25.x env: MIX_ENV: test steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: erlef/setup-beam@v1 with: otp-version: ${{matrix.otp}} @@ -54,11 +49,7 @@ jobs: mix local.hex --force mix local.rebar --force mix deps.get --only test - - run: mix format --check-formatted - if: matrix.check_formatted - - run: mix compile --warnings-as-errors - if: matrix.warnings_as_errors - - run: mix test || ${{ matrix.tests_may_fail }} + - run: mix test static_analysis: name: static analysis (Elixir ${{matrix.elixir}} | Erlang/OTP ${{matrix.otp}}) @@ -66,16 +57,16 @@ jobs: strategy: matrix: include: - - elixir: 1.13.x - otp: 24.1.x + - elixir: 1.14.x + otp: 25.x steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: erlef/setup-beam@v1 with: otp-version: ${{matrix.otp}} elixir-version: ${{matrix.elixir}} - name: Cache build artifacts - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: | ~/.hex @@ -89,5 +80,6 @@ jobs: mix deps.get - name: Restore timestamps to prevent unnecessary recompilation run: IFS=$'\n'; for f in $(git ls-files); do touch -d "$(git log -n 1 --pretty='%cI' -- $f)" "$f"; done - - run: mix dialyzer - if: matrix.run_dialyzer + - run: mix format --check-formatted + - run: cd apps/language_server && mix format --check-formatted + - run: mix dialyzer_vendored diff --git a/.github/workflows/docsite.yml b/.github/workflows/docsite.yml index c7f083f25..0bdaaea4f 100644 --- a/.github/workflows/docsite.yml +++ b/.github/workflows/docsite.yml @@ -15,11 +15,11 @@ jobs: image: squidfunk/mkdocs-material steps: - name: Checkout - uses: actions/checkout@v2.3.1 + uses: actions/checkout@v3 - name: Build run: mkdocs build -s - name: Upload artifact - uses: actions/upload-artifact@v1 + uses: actions/upload-artifact@v3 with: name: site path: site @@ -32,9 +32,9 @@ jobs: max-parallel: 1 steps: - name: Checkout - uses: actions/checkout@v2.3.1 + uses: actions/checkout@v3 - name: Download artifact - uses: actions/download-artifact@v1 + uses: actions/download-artifact@v3 with: name: site path: site diff --git a/.github/workflows/release-asset.yml b/.github/workflows/release-asset.yml index 831900b9c..57dce27ae 100644 --- a/.github/workflows/release-asset.yml +++ b/.github/workflows/release-asset.yml @@ -32,18 +32,30 @@ jobs: strategy: matrix: include: + - elixir-version: '1.14' + otp-version: '25.1' + - elixir-version: '1.14' + otp-version: '24.3' + - elixir-version: '1.14' + otp-version: '23.3' + - elixir-version: '1.13' + otp-version: '25.1' + - elixir-version: '1.13' + otp-version: '24.3' + - elixir-version: '1.13' + otp-version: '23.3' - elixir-version: '1.13' - otp-version: '24.1' + otp-version: '22.3' + - elixir-version: '1.12' + otp-version: '24.3' - elixir-version: '1.12' - otp-version: '24.1' - - elixir-version: '1.11' otp-version: '23.3' - - elixir-version: '1.10' + - elixir-version: '1.12' otp-version: '22.3' default: true steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up BEAM uses: erlef/setup-beam@v1 with: @@ -51,7 +63,7 @@ jobs: otp-version: ${{ matrix.otp-version }} - name: Restore dependencies cache - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: deps key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} @@ -74,7 +86,7 @@ jobs: with: upload_url: ${{ needs.release.outputs.upload_url }} asset_path: ./elixir-ls.zip - asset_name: elixir-ls-${{ matrix.elixir-version }}.zip + asset_name: elixir-ls-${{ matrix.elixir-version }}-${{ matrix.otp-version }}.zip asset_content_type: application/zip - name: Upload Default Release Asset diff --git a/.release-tool-versions b/.release-tool-versions index 8ee835a58..09c810961 100644 --- a/.release-tool-versions +++ b/.release-tool-versions @@ -3,5 +3,5 @@ # # The versions selected here are the versions that are used to build a binary # release for distribution -elixir 1.10.4-otp-22 +elixir 1.12.3-otp-22 erlang 22.3.4.20 diff --git a/CHANGELOG.md b/CHANGELOG.md index 98de14dff..a2ce6b528 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,103 @@ ### Unreleased +**Deprecations** +- Minimum version of Elixir is now 1.12.3 + +### v0.12.0: 7 November 2022 + +Improvements: + +- Support for list destructuring and comprehension in `for` and `with` expressions. ElixirLS is able to provide completions for destructured list element +- Introduction of compile tracers. ElixirLS now builds a databases basing on compile tracers API available since elixir 1.10. References provider has been rewritten to support tracer database +- Code action prefixing unused variables with `_` [Luca Cervello](https://github.com/lucacervello) +- Complete now proposes not aliased modules and adds required `alias` [Ajay](https://github.com/ajayvigneshk) +- Custom command running mix clean added. Useful when server hits a compilation error +- Custom command returning tests in `.exs` file +- Better handling of Phoenix components [Aaron Tinio](https://github.com/aptinio) +- Test code lense improvements in umbrella apps [我没有抓狂](https://github.com/BlindingDark) +- Start script improved when `$XDG_CONFIG_HOME` is not set [Sahn Lam](https://github.com/slam) +- Deprecated symbols are now deprioretized in completions +- Improvements to logging +- Dialyxir is now vendored. This should avert dependency conflicts +- ElixirLS emits more helpful error messages in case of common problems +- Automatic builds can now be disabled [Hans](https://github.com/Hanspagh) +- Better module name suggested for `defprotocol` [Milo Lee](https://github.com/oo6) +- Improved LSP position handling + +Fixes: + +- Several crashes with `untitled:` schema URIs fixed +- Longstanding bug in dependencies reloading leading to infamous `** (Mix.Error) Can't continue due to errors on dependencies` fixed +- Fixed crash when formatting a file with syntax errors [Steve Cohen](https://github.com/scohen) +- Fixed several crashes in document symbols [Steve Cohen](https://github.com/scohen) + +### v0.11.0: 14 August 2022 + +Improvements: + +- Elixir 1.14 support +- Document symbols now return non empty selection ranges. This fixes breadcrumbs behavior in vscode +- Fixed dialyzer crash on OTP 25 +- Added support for mix formatter plugins ([Dalibor Horinek](https://github.com/DaliborHorinek)) +- Debugger now returns detailed info about ports, pids and function variables +- Debugger completions now return detal field +- Diagnostic positions now return column position returned by compiler (elixir 1.14+) +- Diagnostic position fixed to never return invalid negative values +- An exact `do` keyword completion is now preselected and more preferred over `defoverridable` +- Fixed hexdoc links in hover for aliased modules and imported functions ([Milo Lee](https://github.com/oo6)) +- Better module name suggestions in Phoenix `live` directory ([Manos Emmanouilidis](https://github.com/bottlenecked)) + +**Deprecations** +- Minimum version of Elixir is now 1.11 + +### v0.10.0: 10 June 2022 + +Improvements to debugger addapter: + +- A lot of new features around breakpoints: function breakpoints, conditional breakpoints, hit count and log points [#656](https://github.com/elixir-lsp/elixir-ls/pull/656), [#661](https://github.com/elixir-lsp/elixir-ls/pull/661), [#671](https://github.com/elixir-lsp/elixir-ls/pull/671) (thanks [Łukasz Samson](https://github.com/lukaszsamson)) +- Completions in debugger eval console [#679](https://github.com/elixir-lsp/elixir-ls/pull/679) (thanks [Łukasz Samson](https://github.com/lukaszsamson)) +- Debugger evaluate results can now be expanded [#672](https://github.com/elixir-lsp/elixir-ls/pull/672) (thanks [Łukasz Samson](https://github.com/lukaszsamson)) +- Messages in the queue of debugged process can now be examined [#681](https://github.com/elixir-lsp/elixir-ls/pull/681) (thanks [Łukasz Samson](https://github.com/lukaszsamson)) +- Debugger can now handle pause and terminateThread requests [#675](https://github.com/elixir-lsp/elixir-ls/pull/675) (thanks [Łukasz Samson](https://github.com/lukaszsamson)) +- Clipboard and hover eval is now supported in debugger [#680](https://github.com/elixir-lsp/elixir-ls/pull/680) (thanks [Łukasz Samson](https://github.com/lukaszsamson)) +- Auto interpreting can now be disabled [#616](https://github.com/elixir-lsp/elixir-ls/pull/616) (thanks [Jason Axelson](https://github.com/axelson)) +- Debugger conforms better to DAP 1.51 specification [#678](https://github.com/elixir-lsp/elixir-ls/pull/678) (thanks [Łukasz Samson](https://github.com/lukaszsamson)) + +Improvements to language server: + +- Language server can now be restarted via custom command (e.g. from VSCode) [#653](https://github.com/elixir-lsp/elixir-ls/pull/653) (thanks [Łukasz Samson](https://github.com/lukaszsamson)) +- Hover provider adds links to hexdocs.pm [#574](https://github.com/elixir-lsp/elixir-ls/pull/574) (thanks [Fenix](https://github.com/zhenfeng-zhu)) +- Numerous cases of invalid UTF8-UTF16 position conversions fixed [#677](https://github.com/elixir-lsp/elixir-ls/pull/677) (thanks [Łukasz Samson](https://github.com/lukaszsamson)) +- Improved markdown wrapping [#663](https://github.com/elixir-lsp/elixir-ls/pull/663) (thanks [我没有抓狂](https://github.com/BlindingDark)) +- Improved MIX_TARGET environment variable handling [#670](https://github.com/elixir-lsp/elixir-ls/pull/670) (thanks [Masatoshi Nishiguchi](https://github.com/mnishiguchi)) +- defmodule snippet now suggests a module name [#684](https://github.com/elixir-lsp/elixir-ls/pull/684) (thanks [Manos Emmanouilidis](https://github.com/bottlenecked)) +- Constant recompilation on Nerves projects fixed [#686](https://github.com/elixir-lsp/elixir-ls/issues/686) (thanks [Łukasz Samson](https://github.com/lukaszsamson)) +- Invalid negative positions in diagnostics are no longer emitted [#695](https://github.com/elixir-lsp/elixir-ls/pull/695) (thanks [Łukasz Samson](https://github.com/lukaszsamson)) +- Improvements to document symbols provider (https://github.com/elixir-lsp/elixir-ls/commit/1e38db4c9dd9277dfffd9563286f652e3d617a5f) (thanks [Łukasz Samson](https://github.com/lukaszsamson)) +- Added support for OTP 25 new dialyzer options (https://github.com/elixir-lsp/elixir-ls/commit/0da7623f644f79559699e9f002820ad9219d108d) (thanks [Łukasz Samson](https://github.com/lukaszsamson)) +- Improvements to complete (operator, sigil, bitstring) [#150](https://github.com/elixir-lsp/elixir_sense/pull/150), (https://github.com/elixir-lsp/elixir_sense/commit/33df514a1254455f54cb069999454c7e8586eb2d) (thanks [Łukasz Samson](https://github.com/lukaszsamson)) +- Improved alias resolution (https://github.com/elixir-lsp/elixir_sense/issues/151) (thanks [Łukasz Samson](https://github.com/lukaszsamson)) +- Fixed crash on OTP 24.2 (https://github.com/elixir-lsp/elixir_sense/commit/72f3d4ffee3c11c289d47d14a6c5f6e1a4afacb4) (thanks [Łukasz Samson](https://github.com/lukaszsamson)) +- Better function detection when hovering inside string interpolation [#152](https://github.com/elixir-lsp/elixir_sense/pull/152) (thanks [Milo Lee](https://github.com/oo6)) +- Support for external plugins to elixir_sense [#141](https://github.com/elixir-lsp/elixir_sense/pull/141) (thanks [Zach Daniel](https://github.com/zachdaniel)) + +VSCode: + +- To Pipe and From Pipe code transformation command [#182](https://github.com/elixir-lsp/vscode-elixir-ls/pull/182) (thanks [Paulo Valente](https://github.com/polvalente)) +- Restart language server command added [#218](https://github.com/elixir-lsp/vscode-elixir-ls/pull/218) (thanks [Łukasz Samson](https://github.com/lukaszsamson)) +- New settings related to auto interpreting in debugger (https://github.com/elixir-lsp/vscode-elixir-ls/commit/4294f9f0da6819e519aa4278f5f2d553ff054dac) (thanks [Jason Axelson](https://github.com/axelson)) +- New OTP 25 dialyzer settings (https://github.com/elixir-lsp/vscode-elixir-ls/commit/50a8a53fa79c14d2ea4031f872ec3d7cd32155f5) (thanks [Łukasz Samson](https://github.com/lukaszsamson)) +- Compile time environment variables can now be set in extension config [#213](https://github.com/elixir-lsp/vscode-elixir-ls/pull/213) (thanks [vacarsu](https://github.com/vacarsu)) +- Additional watched extensions can now be set in extension config [#197](https://github.com/elixir-lsp/vscode-elixir-ls/pull/197) (thanks [Vanja Bucic](https://github.com/vanjabucic)) +- Improved unquite_slicing highlighting [#221](https://github.com/elixir-lsp/vscode-elixir-ls/pull/221) (thanks [Milo Lee](https://github.com/oo6)) +- Improved string interpolation highlighting [#229](https://github.com/elixir-lsp/vscode-elixir-ls/pull/229) (thanks [Milo Lee](https://github.com/oo6)) +- Improved regex with < highlighting [#226](https://github.com/elixir-lsp/vscode-elixir-ls/pull/226) (thanks [Tiago Moraes](https://github.com/tiagoefmoraes)) +- Extension updated to use LSP v3.16 [#227](https://github.com/elixir-lsp/vscode-elixir-ls/pull/227) (thanks [Łukasz Samson](https://github.com/lukaszsamson)) + +Housekeeping: + +thanks [Łukasz Samson](https://github.com/lukaszsamson), [Thanabodee Charoenpiriyakij](https://github.com/wingyplus), [Daniils Petrovs](https://github.com/DaniruKun), [Jason Axelson](https://github.com/axelson) + ### v0.9.0: 4 December 2021 Improvements: diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..16380faef --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,62 @@ +# Code of Conduct + +Contact: elixir-ls-coc@googlegroups.com + +## Why have a Code of Conduct? + +As contributors and maintainers of this project, we are committed to providing a friendly, safe and welcoming environment for all, regardless of age, disability, gender, nationality, race, religion, sexuality, or similar personal characteristic. + +The goal of the Code of Conduct is to specify a baseline standard of behavior so that people with different social values and communication styles can talk about Elixir effectively, productively, and respectfully, even in face of disagreements. The Code of Conduct also provides a mechanism for resolving conflicts in the community when they arise. + +## Our Values + +These are the values ElixirLS developers should aspire to: + + * Be friendly and welcoming + * Be kind + * Remember that people have varying communication styles and that not everyone is using their native language. (Meaning and tone can be lost in translation.) + * Interpret the arguments of others in good faith, do not seek to disagree. + * When we do disagree, try to understand why. + * Be thoughtful + * Productive communication requires effort. Think about how your words will be interpreted. + * Remember that sometimes it is best to refrain entirely from commenting. + * Be respectful + * In particular, respect differences of opinion. It is important that we resolve disagreements and differing views constructively. + * Be constructive + * Avoid derailing: stay on topic; if you want to talk about something else, start a new conversation. + * Avoid unconstructive criticism: don't merely decry the current state of affairs; offer — or at least solicit — suggestions as to how things may be improved. + * Avoid harsh words and stern tone: we are all aligned towards the well-being of the community and the progress of the ecosystem. Harsh words exclude, demotivate, and lead to unnecessary conflict. + * Avoid snarking (pithy, unproductive, sniping comments). + * Avoid microaggressions (brief and commonplace verbal, behavioral and environmental indignities that communicate hostile, derogatory or negative slights and insults towards a project, person or group). + * Be responsible + * What you say and do matters. Take responsibility for your words and actions, including their consequences, whether intended or otherwise. + +The following actions are explicitly forbidden: + + * Insulting, demeaning, hateful, or threatening remarks. + * Discrimination based on age, disability, gender, nationality, race, religion, sexuality, or similar personal characteristic. + * Bullying or systematic harassment. + * Unwelcome sexual advances. + * Incitement to any of these. + +## Where does the Code of Conduct apply? + +If you participate in or contribute to the ElixirLS in any way, you are encouraged to follow the Code of Conduct while doing so. + +Explicit enforcement of the Code of Conduct applies to the official mediums operated by the ElixirLS project: + +* The [official GitHub projects][1] and code reviews. + +Other ElixirLS activities (such as conferences, meetups, and unofficial forums) are encouraged to adopt this Code of Conduct. Such groups must provide their own contact information. + +Project maintainers may block, remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by emailing: elixir-ls-coc@googlegroups.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. **All reports will be kept confidential**. + +**The goal of the Code of Conduct is to resolve conflicts in the most harmonious way possible**. We hope that in most cases issues may be resolved through polite discussion and mutual agreement. Bannings and other forceful measures are to be employed only as a last resort. **Do not** post about the issue publicly or try to rally sentiment against a particular individual or group. + +## Acknowledgements + +This document was based on the Code of Conduct from the Go project (dated Sep/2021) and the Contributor Covenant (v1.4). + +[1]: https://github.com/elixir-lsp/ diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index bffddb8d7..470911a01 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,4 +1,6 @@ -# Version Support Guidelines +# Development + +## Version Support Guidelines Elixir itself supports 5 versions with security updates: https://hexdocs.pm/elixir/compatibility-and-deprecations.html#content @@ -8,30 +10,32 @@ http://erlang.2086793.n4.nabble.com/OTP-Versions-and-Maint-Branches-td4722416.ht ElixirLS generally aims to support the last 3 versions of Elixir and the last 3 versions of OTP. However this is not a hard and fast rule and may change in the future. -# Packaging +## Packaging + +Follow those instructions when publishing a new release. -Bump the changelog -Bump the version numbers in `apps/elixir_ls_debugger/mix.exs`, `apps/elixir_ls_utils/mix.exs`, and `apps/language_server/mix.exs` -Make PR -Merge PR -Pull down the latest master -Make the tag from the new master -Push the tag (`git push upstream --tags`) -Wait for github actions to push up a draft release https://github.com/elixir-lsp/elixir-ls/releases -Edit the draft release with a link to the changelog -Publish the draft release +1. Bump the changelog +2. Bump the version numbers in `apps/elixir_ls_debugger/mix.exs`, `apps/elixir_ls_utils/mix.exs`, and `apps/language_server/mix.exs` +3. Make PR +4. Merge PR +5. Pull down the latest master +6. Make the tag from the new master +7. Push the tag (`git push upstream --tags`) +8. Wait for github actions to push up a draft release https://github.com/elixir-lsp/elixir-ls/releases +9. Edit the draft release with a link to the changelog +10. Publish the draft release -# Debugging +## Debugging If you're debugging a running server than `IO.inspect` is a good approach, any messages you create with it will be sent to your LSP client as a log message To debug in tests you can use `IO.inspect(Process.whereis(:user), message, label: "message")` to send your output directly to the group leader of the test process. -# Documentation website +## Documentation website The documentation website is built using the [Mkdocs](https://www.mkdocs.org) static website generator. The content is written in Markdown format in the directory [docs](./docs) and is configured via the [mkdocs.yml](./mkdocs.yml) file. -## Development +### Development Make sure you have a recent version of Python 3 and [Pip](https://pip.readthedocs.io/en/stable/installing/) installed. @@ -43,6 +47,6 @@ pip install mkdocs mkdocs-material Once installed, simply run `mkdocs serve` from the project root. This will start a local web server with a file watcher. -## Build +### Build To compile the website for deployment, run `mkdocs build` from the project root. The built static assets will be located in the `site` directory. These can then be served by any web hosting solution. diff --git a/README.md b/README.md index 16f11e466..d1354944b 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Note: On first run Dialyzer will build a PLT cache which will take a considerabl | Kate | [built-in LSP Client plugin](https://kate-editor.org/post/2020/2020-01-01-kate-lsp-client-status/) | Does not support debugger | | Neovim | [coc.nvim](https://github.com/neoclide/coc.nvim) | Does not support debugger | | Neovim | [nvim-dap](https://github.com/mfussenegger/nvim-dap) | Supports debugger only | +| Neovim | [nvim-lspconfig](https://github.com/neovim/nvim-lspconfig) | Does not support debugger | | Nova | [nova-elixir-ls](https://github.com/raulchedrese/nova-elixir-ls) | | | Sublime Text | [LSP-elixir](https://github.com/sublimelsp/LSP-elixir) | Does not support debugger | | Vim/Neovim | [ALE](https://github.com/w0rp/ale) | Does not support debugger or @spec suggestions | @@ -87,7 +88,7 @@ For VSCode install the extension: https://marketplace.visualstudio.com/items?ite Elixir: -- 1.10.0 minimum +- 1.11.0 minimum Erlang: @@ -103,7 +104,7 @@ When debugging in Elixir or Erlang, only modules that have been "interpreted" (u Currently there is a limit of 100 breakpoints. -### Debuging tests and `.exs` files +### Debugging tests and `.exs` files In order to debug modules in `.exs` files (such as tests), they must be specified under `requireFiles` in your launch configuration so they can be loaded and interpreted prior to running the task. For example, the default launch configuration for "mix test" in the VS Code plugin looks like this: @@ -192,15 +193,15 @@ Break conditions are supported and evaluate elixir expressions within the contex ### Hit conditions -An expression that evaluates to integer can be used to contro how many hits of a breakpoint are ignored before the process is stopped. +An expression that evaluates to integer can be used to control how many hits of a breakpoint are ignored before the process is stopped. ### Log points -When log message is set on a breakpoint the debugger will not break but instead log a message to standard output (as required by Debug Adapter Protocol specification). The message may contain interpolated expressions in `{}`, e.g. `my_var is {inspect(my_var)}` and will be evaluated in the context of the process. Special characters `{` and `}` can be emited with escape sequence `\{` and `\}`. As of Debug Adapter Protocol specification version 1.51, log messages are not supported on function breakpoints. +When log message is set on a breakpoint the debugger will not break but instead log a message to standard output (as required by Debug Adapter Protocol specification). The message may contain interpolated expressions in `{}`, e.g. `my_var is {inspect(my_var)}` and will be evaluated in the context of the process. Special characters `{` and `}` can be emitted with escape sequence `\{` and `\}`. As of Debug Adapter Protocol specification version 1.51, log messages are not supported on function breakpoints. ### Expression evaluator -An expression evaluator is included in the debbuger. It evaluates elixir expressions in the context of a process stopped on a breakpoint. All bound variables are accessible (no support for attributes as those are compile time). Please note that there are limitations due to `:int` operating on beam instruction level. The binding returns multiple versions of variables in Static Singe Assignment with no indication which one is valid in the current elixir scope. A heuristic is used that selects the highest versions but it does not behave correctly in all cases, e.g. in +An expression evaluator is included in the debugger. It evaluates elixir expressions in the context of a process stopped on a breakpoint. All bound variables are accessible (no support for attributes as those are compile time). Please note that there are limitations due to `:int` operating on beam instruction level. The binding returns multiple versions of variables in Static Singe Assignment with no indication which one is valid in the current elixir scope. A heuristic is used that selects the highest versions but it does not behave correctly in all cases, e.g. in ```elixir a = 4 @@ -284,7 +285,6 @@ https://github.com/elixir-lsp/elixir-ls/issues/364#issuecomment-829589139 * `.exs` files don't return compilation errors * "Fetching n dependencies" sometimes get stuck (remove the `.elixir_ls` directory to fix) -* Debugger doesn't work in Elixir 1.10.0 - 1.10.2 (but it should work in 1.10.3 when [this fix](https://github.com/elixir-lang/elixir/pull/9864) is released) * "Go to definition" does not work within the `scope` of a Phoenix router * On first launch dialyzer will cause high CPU usage for a considerable time * Dialyzer does not pick up changes involving remote types (https://github.com/elixir-lsp/elixir-ls/issues/502) diff --git a/apps/elixir_ls_debugger/lib/debugger.ex b/apps/elixir_ls_debugger/lib/debugger.ex index 09484cf0a..64d253ba5 100644 --- a/apps/elixir_ls_debugger/lib/debugger.ex +++ b/apps/elixir_ls_debugger/lib/debugger.ex @@ -4,12 +4,13 @@ defmodule ElixirLS.Debugger do """ use Application + alias ElixirLS.Debugger.Output @impl Application def start(_type, _args) do # We don't start this as a worker because if the debugger crashes, we want # this process to remain alive to print errors - {:ok, _pid} = ElixirLS.Debugger.Output.start(ElixirLS.Debugger.Output) + {:ok, _pid} = Output.start(Output) children = [ {ElixirLS.Debugger.Server, name: ElixirLS.Debugger.Server} @@ -22,7 +23,7 @@ defmodule ElixirLS.Debugger do @impl Application def stop(_state) do if ElixirLS.Utils.WireProtocol.io_intercepted?() do - IO.puts(:standard_error, "ElixirLS debugger has crashed") + Output.debugger_important("ElixirLS debugger has crashed") :init.stop(1) end diff --git a/apps/elixir_ls_debugger/lib/debugger/binding.ex b/apps/elixir_ls_debugger/lib/debugger/binding.ex index 7bf9b916c..846541b47 100644 --- a/apps/elixir_ls_debugger/lib/debugger/binding.ex +++ b/apps/elixir_ls_debugger/lib/debugger/binding.ex @@ -8,7 +8,7 @@ defmodule ElixirLS.Debugger.Binding do end) |> Enum.map(fn {classic_key, list} -> # assume binding with highest number is the current one - # this may not be allways true, e.g. in + # this may not be always true, e.g. in # a = 5 # if true do # a = 4 diff --git a/apps/elixir_ls_debugger/lib/debugger/breakpoint_condition.ex b/apps/elixir_ls_debugger/lib/debugger/breakpoint_condition.ex index 10ab34b58..6636a030c 100644 --- a/apps/elixir_ls_debugger/lib/debugger/breakpoint_condition.ex +++ b/apps/elixir_ls_debugger/lib/debugger/breakpoint_condition.ex @@ -4,6 +4,7 @@ defmodule ElixirLS.Debugger.BreakpointCondition do """ use GenServer + alias ElixirLS.Debugger.Output @range 0..99 def start_link(args) do @@ -38,7 +39,8 @@ defmodule ElixirLS.Debugger.BreakpointCondition do GenServer.call(name, {:has_condition?, {module, lines}}) end - @spec get_condition(module, non_neg_integer) :: {String.t(), non_neg_integer, non_neg_integer} + @spec get_condition(module, non_neg_integer) :: + {String.t(), String.t(), non_neg_integer, non_neg_integer} def get_condition(name \\ __MODULE__, number) do GenServer.call(name, {:get_condition, number}) end @@ -162,7 +164,7 @@ defmodule ElixirLS.Debugger.BreakpointCondition do # Debug Adapter Protocol: # If this attribute exists and is non-empty, the backend must not 'break' (stop) # but log the message instead. Expressions within {} are interpolated. - IO.puts(interpolate(log_message, elixir_binding)) + Output.debugger_console(interpolate(log_message, elixir_binding)) false else result @@ -170,6 +172,7 @@ defmodule ElixirLS.Debugger.BreakpointCondition do end end + @spec eval_condition(String.t(), keyword) :: boolean def eval_condition("true", _binding), do: true def eval_condition(condition, elixir_binding) do @@ -178,7 +181,10 @@ defmodule ElixirLS.Debugger.BreakpointCondition do if term, do: true, else: false catch kind, error -> - IO.warn("Error in conditional breakpoint: " <> Exception.format_banner(kind, error)) + Output.debugger_important( + "Error in conditional breakpoint: " <> Exception.format_banner(kind, error) + ) + false end end @@ -189,7 +195,10 @@ defmodule ElixirLS.Debugger.BreakpointCondition do to_string(term) catch kind, error -> - IO.warn("Error in log message interpolation: " <> Exception.format_banner(kind, error)) + Output.debugger_important( + "Error in log message interpolation: " <> Exception.format_banner(kind, error) + ) + "" end end @@ -220,7 +229,7 @@ defmodule ElixirLS.Debugger.BreakpointCondition do interpolate(expression_rest, [eval_result | acc], elixir_binding) :error -> - IO.warn("Log message has unpaired or nested `{}`") + Output.debugger_important("Log message has unpaired or nested `{}`") acc end end diff --git a/apps/elixir_ls_debugger/lib/debugger/cli.ex b/apps/elixir_ls_debugger/lib/debugger/cli.ex index b597191bd..c995fe37c 100644 --- a/apps/elixir_ls_debugger/lib/debugger/cli.ex +++ b/apps/elixir_ls_debugger/lib/debugger/cli.ex @@ -3,12 +3,21 @@ defmodule ElixirLS.Debugger.CLI do alias ElixirLS.Debugger.{Output, Server} def main do - WireProtocol.intercept_output(&Output.print/1, &Output.print_err/1) + WireProtocol.intercept_output(&Output.debuggee_out/1, &Output.debuggee_err/1) Launch.start_mix() {:ok, _} = Application.ensure_all_started(:elixir_ls_debugger, :permanent) - IO.puts("Started ElixirLS debugger v#{Launch.debugger_version()}") - Launch.print_versions() + Output.debugger_console("Started ElixirLS Debugger v#{Launch.debugger_version()}") + versions = Launch.get_versions() + + Output.debugger_console( + "ElixirLS Debugger built with elixir #{versions.compile_elixir_version} on OTP #{versions.compile_otp_version}" + ) + + Output.debugger_console( + "Running on elixir #{versions.current_elixir_version} on OTP #{versions.current_otp_version}" + ) + Launch.limit_num_schedulers() warn_if_unsupported_version() WireProtocol.stream_packets(&Server.receive_packet/1) @@ -16,24 +25,11 @@ defmodule ElixirLS.Debugger.CLI do defp warn_if_unsupported_version do with {:error, message} <- ElixirLS.Utils.MinimumVersion.check_elixir_version() do - Output.print_err("WARNING: " <> message) + Output.debugger_important("WARNING: " <> message) end with {:error, message} <- ElixirLS.Utils.MinimumVersion.check_otp_version() do - Output.print_err("WARNING: " <> message) - end - - # Debugging does not work on Elixir 1.10.0-1.10.2: - # https://github.com/elixir-lsp/elixir-ls/issues/158 - elixir_version = System.version() - - if Version.match?(elixir_version, ">= 1.10.0") && Version.match?(elixir_version, "< 1.10.3") do - message = - "WARNING: Debugging is not supported on Elixir #{elixir_version}. Please upgrade" <> - " to at least 1.10.3\n" <> - "more info: https://github.com/elixir-lsp/elixir-ls/issues/158" - - Output.print_err(message) + Output.debugger_important("WARNING: " <> message) end end end diff --git a/apps/elixir_ls_debugger/lib/debugger/completions.ex b/apps/elixir_ls_debugger/lib/debugger/completions.ex new file mode 100644 index 000000000..45e8d1ed0 --- /dev/null +++ b/apps/elixir_ls_debugger/lib/debugger/completions.ex @@ -0,0 +1,67 @@ +defmodule ElixirLS.Debugger.Completions do + # type CompletionItemType = 'method' | 'function' | 'constructor' | 'field' + # | 'variable' | 'class' | 'interface' | 'module' | 'property' | 'unit' + # | 'value' | 'enum' | 'keyword' | 'snippet' | 'text' | 'color' | 'file' + # | 'reference' | 'customcolor'; + def map(%{ + type: type, + name: name, + arity: arity, + snippet: snippet + }) + when type in [:function, :macro] do + %{ + type: "function", + detail: Atom.to_string(type), + label: "#{name}/#{arity}", + text: snippet || name + } + end + + def map(%{ + type: :module, + subtype: subtype, + name: name + }) do + text = + case name do + ":" <> rest -> rest + other -> other + end + + %{ + type: "module", + detail: if(subtype != nil, do: Atom.to_string(subtype)), + label: name, + text: text + } + end + + def map(%{ + type: :variable, + name: name + }) do + %{ + type: "variable", + label: name + } + end + + def map(%{ + type: :field, + subtype: subtype, + name: name + }) do + detail = + case subtype do + :struct_field -> "struct field" + :map_key -> "map key" + end + + %{ + type: "field", + detail: detail, + label: name + } + end +end diff --git a/apps/elixir_ls_debugger/lib/debugger/output.ex b/apps/elixir_ls_debugger/lib/debugger/output.ex index bd1481c37..ca19e4db1 100644 --- a/apps/elixir_ls_debugger/lib/debugger/output.ex +++ b/apps/elixir_ls_debugger/lib/debugger/output.ex @@ -29,11 +29,19 @@ defmodule ElixirLS.Debugger.Output do GenServer.call(server, {:send_event, event, body}) end - def print(server \\ __MODULE__, str) when is_binary(str) do + def debugger_console(server \\ __MODULE__, str) when is_binary(str) do + send_event(server, "output", %{"category" => "console", "output" => str}) + end + + def debugger_important(server \\ __MODULE__, str) when is_binary(str) do + send_event(server, "output", %{"category" => "important", "output" => str}) + end + + def debuggee_out(server \\ __MODULE__, str) when is_binary(str) do send_event(server, "output", %{"category" => "stdout", "output" => str}) end - def print_err(server \\ __MODULE__, str) when is_binary(str) do + def debuggee_err(server \\ __MODULE__, str) when is_binary(str) do send_event(server, "output", %{"category" => "stderr", "output" => str}) end diff --git a/apps/elixir_ls_debugger/lib/debugger/protocol.ex b/apps/elixir_ls_debugger/lib/debugger/protocol.ex index b5f1a9e38..fb23e916d 100644 --- a/apps/elixir_ls_debugger/lib/debugger/protocol.ex +++ b/apps/elixir_ls_debugger/lib/debugger/protocol.ex @@ -55,6 +55,18 @@ defmodule ElixirLS.Debugger.Protocol do end end + defmacro terminate_threads_req(seq, thread_ids) do + quote do + request(unquote(seq), "terminateThreads", %{"threadIds" => unquote(thread_ids)}) + end + end + + defmacro pause_req(seq, thread_id) do + quote do + request(unquote(seq), "pause", %{"threadId" => unquote(thread_id)}) + end + end + defmacro stacktrace_req(seq, thread_id) do quote do request(unquote(seq), "stackTrace", %{"threadId" => unquote(thread_id)}) @@ -73,6 +85,12 @@ defmodule ElixirLS.Debugger.Protocol do end end + defmacro completions_req(seq, text) do + quote do + request(unquote(seq), "completions", %{"text" => unquote(text)}) + end + end + defmacro continue_req(seq, thread_id) do quote do request(unquote(seq), "continue", %{"threadId" => unquote(thread_id)}) diff --git a/apps/elixir_ls_debugger/lib/debugger/server.ex b/apps/elixir_ls_debugger/lib/debugger/server.ex index 2149371ff..7cea4e2f5 100644 --- a/apps/elixir_ls_debugger/lib/debugger/server.ex +++ b/apps/elixir_ls_debugger/lib/debugger/server.ex @@ -25,6 +25,7 @@ defmodule ElixirLS.Debugger.Server do } alias ElixirLS.Debugger.Stacktrace.Frame + alias ElixirLS.Utils.MixfileHelpers use GenServer use Protocol @@ -35,7 +36,12 @@ defmodule ElixirLS.Debugger.Server do task_ref: nil, threads: %{}, threads_inverse: %{}, - paused_processes: %{}, + paused_processes: %{ + evaluator: %{ + vars: %{}, + vars_inverse: %{} + } + }, next_id: 1, output: Output, breakpoints: %{}, @@ -66,6 +72,10 @@ defmodule ElixirLS.Debugger.Server do GenServer.cast(server, {:breakpoint_reached, pid}) end + def paused(pid, server) do + GenServer.cast(server, {:paused, pid}) + end + ## Server Callbacks @impl GenServer @@ -111,7 +121,8 @@ defmodule ElixirLS.Debugger.Server do end @impl GenServer - def handle_cast({:breakpoint_reached, pid}, state = %__MODULE__{}) do + def handle_cast({event, pid}, state = %__MODULE__{}) + when event in [:breakpoint_reached, :paused] do # when debugged pid exits we get another breakpoint reached message (at least on OTP 23) # check if process is alive to not debug dead ones state = @@ -123,12 +134,23 @@ defmodule ElixirLS.Debugger.Server do paused_process = %PausedProcess{stack: Stacktrace.get(pid), ref: ref} state = put_in(state.paused_processes[pid], paused_process) - # Debugger Adapter Protocol requires us to return 'function breakpoint' reason - # but we can't tell what kind of a breakpoint was hit - body = %{"reason" => "breakpoint", "threadId" => thread_id, "allThreadsStopped" => false} + reason = + case event do + :breakpoint_reached -> + # Debugger Adapter Protocol requires us to return 'step' | 'breakpoint' | 'exception' | 'pause' | 'entry' | 'goto' + # | 'function breakpoint' | 'data breakpoint' | 'instruction breakpoint' + # but we can't tell what kind of a breakpoint was hit + "breakpoint" + + :paused -> + "pause" + end + + body = %{"reason" => reason, "threadId" => thread_id, "allThreadsStopped" => false} Output.send_event("stopped", body) state else + Process.monitor(pid) state end @@ -144,8 +166,7 @@ defmodule ElixirLS.Debugger.Server do 0 _ -> - IO.puts( - :standard_error, + Output.debugger_important( "(Debugger) Task failed because " <> Exception.format_exit(reason) ) @@ -159,24 +180,26 @@ defmodule ElixirLS.Debugger.Server do end def handle_info({:DOWN, _ref, :process, pid, reason}, state = %__MODULE__{}) do - IO.puts( - :standard_error, + Output.debugger_important( "debugged process #{inspect(pid)} exited with reason #{Exception.format_exit(reason)}" ) - thread_id = state.threads_inverse[pid] - state = remove_paused_process(state, pid) + {thread_id, threads_inverse} = state.threads_inverse |> Map.pop(pid) + paused_processes = remove_paused_process(state, pid) state = %{ state | threads: state.threads |> Map.delete(thread_id), - threads_inverse: state.threads_inverse |> Map.delete(pid) + paused_processes: paused_processes, + threads_inverse: threads_inverse } - Output.send_event("thread", %{ - "reason" => "exited", - "threadId" => thread_id - }) + if thread_id do + Output.send_event("thread", %{ + "reason" => "exited", + "threadId" => thread_id + }) + end {:noreply, state} end @@ -197,13 +220,28 @@ defmodule ElixirLS.Debugger.Server do @impl GenServer def terminate(reason, _state = %__MODULE__{}) do if reason != :normal do - IO.puts(:standard_error, "(Debugger) Terminating because #{Exception.format_exit(reason)}") + Output.debugger_important("(Debugger) Terminating because #{Exception.format_exit(reason)}") end end ## Helpers defp handle_request(initialize_req(_, client_info), %__MODULE__{client_info: nil} = state) do + # linesStartAt1 is true by default and we only support 1-based indexing + if client_info["linesStartAt1"] == false do + Output.debugger_important("0-based lines are not supported") + end + + # columnsStartAt1 is true by default and we only support 1-based indexing + if client_info["columnsStartAt1"] == false do + Output.debugger_important("0-based columns are not supported") + end + + # pathFormat is `path` by default and we do not support other, e.g. `uri` + if client_info["pathFormat"] not in [nil, "path"] do + Output.debugger_important("pathFormat #{client_info["pathFormat"]} not supported") + end + {capabilities(), %{state | client_info: client_info}} end @@ -216,14 +254,17 @@ defmodule ElixirLS.Debugger.Server do } end - defp handle_request(launch_req(_, config), state = %__MODULE__{}) do + defp handle_request(launch_req(_, config) = args, state = %__MODULE__{}) do + if args["arguments"]["noDebug"] == true do + Output.debugger_important("launch with no debug is not supported") + end + {_, ref} = spawn_monitor(fn -> initialize(config) end) receive do {:DOWN, ^ref, :process, _pid, reason} -> if reason != :normal do - IO.puts( - :standard_error, + Output.debugger_important( "(Debugger) Initialization failed because " <> Exception.format_exit(reason) ) @@ -293,7 +334,9 @@ defmodule ElixirLS.Debugger.Server do :ok {:error, :function_not_found} -> - IO.warn("Unable to delete function breakpoint on #{inspect({m, f, a})}") + Output.debugger_important( + "Unable to delete function breakpoint on #{inspect({m, f, a})}" + ) end end @@ -356,8 +399,7 @@ defmodule ElixirLS.Debugger.Server do end defp handle_request(configuration_done_req(_), state = %__MODULE__{}) do - server = :erlang.process_info(self())[:registered_name] || self() - :int.auto_attach([:break], {__MODULE__, :breakpoint_reached, [server]}) + :int.auto_attach([:break], build_attach_mfa(:breakpoint_reached)) task = state.config["task"] || Mix.Project.config()[:default_task] args = state.config["taskArgs"] || [] @@ -391,6 +433,28 @@ defmodule ElixirLS.Debugger.Server do {%{"threads" => threads}, state} end + defp handle_request(terminate_threads_req(_, thread_ids), state = %__MODULE__{}) do + for {id, pid} <- state.threads, + id in thread_ids do + # :kill is untrappable + # do not need to cleanup here, :DOWN message handler will do it + Process.monitor(pid) + Process.exit(pid, :kill) + end + + {%{}, state} + end + + defp handle_request(pause_req(_, thread_id), state = %__MODULE__{}) do + pid = state.threads[thread_id] + + if pid do + :int.attach(pid, build_attach_mfa(:paused)) + end + + {%{}, state} + end + defp handle_request( request(_, "stackTrace", %{"threadId" => thread_id} = args), state = %__MODULE__{} @@ -445,6 +509,9 @@ defmodule ElixirLS.Debugger.Server do {pid, %Frame{} = frame} -> {state, args_id} = ensure_var_id(state, pid, frame.args) {state, bindings_id} = ensure_var_id(state, pid, frame.bindings) + {state, messages_id} = ensure_var_id(state, pid, frame.messages) + process_info = Process.info(pid) + {state, process_info_id} = ensure_var_id(state, pid, process_info) vars_scope = %{ "name" => "variables", @@ -462,7 +529,27 @@ defmodule ElixirLS.Debugger.Server do "expensive" => false } - scopes = if Enum.count(frame.args) > 0, do: [vars_scope, args_scope], else: [vars_scope] + messages_scope = %{ + "name" => "messages", + "variablesReference" => messages_id, + "namedVariables" => 0, + "indexedVariables" => Enum.count(frame.messages), + "expensive" => false + } + + process_info_scope = %{ + "name" => "process info", + "variablesReference" => process_info_id, + "namedVariables" => length(process_info), + "indexedVariables" => 0, + "expensive" => false + } + + scopes = + [vars_scope, process_info_scope] + |> Kernel.++(if Enum.count(frame.args) > 0, do: [args_scope], else: []) + |> Kernel.++(if Enum.count(frame.messages) > 0, do: [messages_scope], else: []) + {state, scopes} nil -> @@ -499,87 +586,122 @@ defmodule ElixirLS.Debugger.Server do end defp handle_request( - request(_cmd, "evaluate", %{"expression" => expr} = _args), + request(_cmd, "evaluate", %{"expression" => expr} = args), state = %__MODULE__{} ) do timeout = Map.get(state.config, "debugExpressionTimeoutMs", 10_000) - bindings = all_variables(state.paused_processes) + bindings = all_variables(state.paused_processes, args["frameId"]) result = evaluate_code_expression(expr, bindings, timeout) - {%{"result" => inspect(result), "variablesReference" => 0}, state} + case result do + {:ok, value} -> + child_type = Variables.child_type(value) + {state, var_id} = get_variable_reference(child_type, state, :evaluator, value) + + json = + %{ + "result" => inspect(value), + "variablesReference" => var_id + } + |> maybe_append_children_number(state.client_info, child_type, value) + |> maybe_append_variable_type(state.client_info, value) + + {json, state} + + other -> + result_string = + if args["context"] == "hover" do + # avoid displaying hover info when evaluation crashed + "" + else + inspect(other) + end + + json = %{ + "result" => result_string, + "variablesReference" => 0 + } + + {json, state} + end end - defp handle_request(continue_req(_, thread_id), state = %__MODULE__{}) do + defp handle_request(continue_req(_, thread_id) = args, state = %__MODULE__{}) do pid = get_pid_by_thread_id!(state, thread_id) - try do - :int.continue(pid) - state = remove_paused_process(state, pid) - {%{"allThreadsContinued" => false}, state} - rescue - e in MatchError -> - raise ServerError, - message: "serverError", - format: ":int.continue failed: {message}", - variables: %{ - "message" => inspect(Exception.message(e)) - } - end + safe_int_action(pid, :continue) + + paused_processes = remove_paused_process(state, pid) + paused_processes = maybe_continue_other_processes(args, paused_processes, pid) + + processes_paused? = paused_processes |> Map.keys() |> Enum.any?(&is_pid/1) + + {%{"allThreadsContinued" => not processes_paused?}, + %{state | paused_processes: paused_processes}} end - defp handle_request(next_req(_, thread_id), state = %__MODULE__{}) do + defp handle_request(next_req(_, thread_id) = args, state = %__MODULE__{}) do pid = get_pid_by_thread_id!(state, thread_id) - try do - :int.next(pid) - state = remove_paused_process(state, pid) - {%{}, state} - rescue - e in MatchError -> - raise ServerError, - message: "serverError", - format: ":int.next failed: {message}", - variables: %{ - "message" => inspect(Exception.message(e)) - } - end + safe_int_action(pid, :next) + paused_processes = remove_paused_process(state, pid) + + {%{}, + %{state | paused_processes: maybe_continue_other_processes(args, paused_processes, pid)}} end - defp handle_request(step_in_req(_, thread_id), state = %__MODULE__{}) do + defp handle_request(step_in_req(_, thread_id) = args, state = %__MODULE__{}) do pid = get_pid_by_thread_id!(state, thread_id) - try do - :int.step(pid) - state = remove_paused_process(state, pid) - {%{}, state} - rescue - e in MatchError -> - raise ServerError, - message: "serverError", - format: ":int.stop failed: {message}", - variables: %{ - "message" => inspect(Exception.message(e)) - } - end + safe_int_action(pid, :step) + paused_processes = remove_paused_process(state, pid) + + {%{}, + %{state | paused_processes: maybe_continue_other_processes(args, paused_processes, pid)}} end - defp handle_request(step_out_req(_, thread_id), state = %__MODULE__{}) do + defp handle_request(step_out_req(_, thread_id) = args, state = %__MODULE__{}) do pid = get_pid_by_thread_id!(state, thread_id) - try do - :int.finish(pid) - state = remove_paused_process(state, pid) - {%{}, state} - rescue - e in MatchError -> - raise ServerError, - message: "serverError", - format: ":int.finish failed: {message}", - variables: %{ - "message" => inspect(Exception.message(e)) - } - end + safe_int_action(pid, :finish) + paused_processes = remove_paused_process(state, pid) + + {%{}, + %{state | paused_processes: maybe_continue_other_processes(args, paused_processes, pid)}} + end + + defp handle_request(completions_req(_, text) = args, state = %__MODULE__{}) do + # assume that the position is 1-based + line = (args["arguments"]["line"] || 1) - 1 + column = (args["arguments"]["column"] || 1) - 1 + + # for simplicity take only text from the given line up to column + line = + text + |> String.split(["\r\n", "\n", "\r"]) + |> Enum.at(line) + + # it's not documented but VSCode uses utf16 positions + column = Utils.dap_character_to_elixir(line, column) + prefix = String.slice(line, 0, column) + + vars = + all_variables(state.paused_processes, args["arguments"]["frameId"]) + |> Enum.map(fn {name, value} -> + %ElixirSense.Core.State.VarInfo{ + name: name, + type: ElixirSense.Core.Binding.from_var(value) + } + end) + + env = %ElixirSense.Providers.Suggestion.Complete.Env{vars: vars} + + results = + ElixirSense.Providers.Suggestion.Complete.complete(prefix, env) + |> Enum.map(&ElixirLS.Debugger.Completions.map/1) + + {%{"targets" => results}, state} end defp handle_request(request(_, command), _state = %__MODULE__{}) when is_binary(command) do @@ -591,6 +713,38 @@ defmodule ElixirLS.Debugger.Server do } end + defp maybe_continue_other_processes(%{"singleThread" => true}, paused_processes, requested_pid) do + resumed_pids = + for {paused_pid, %PausedProcess{ref: ref}} when paused_pid != requested_pid <- + paused_processes do + safe_int_action(paused_pid, :continue) + true = Process.demonitor(ref, [:flush]) + paused_pid + end + + paused_processes |> Map.drop(resumed_pids) + end + + defp maybe_continue_other_processes(_, paused_processes, _requested_pid), do: paused_processes + + # TODO consider removing this workaround as the problem seems to no longer affect OTP 24 + defp safe_int_action(pid, action) do + apply(:int, action, [pid]) + :ok + catch + kind, payload -> + # when stepping out of interpreted code a MatchError is risen inside :int module (at least in OTP 23) + Output.debugger_important( + ":int.#{action}(#{inspect(pid)}) failed: #{Exception.format(kind, payload)}" + ) + + unless action == :continue do + safe_int_action(pid, :continue) + end + + :ok + end + defp get_pid_by_thread_id!(state = %__MODULE__{}, thread_id) do case state.threads[thread_id] do nil -> @@ -607,9 +761,13 @@ defmodule ElixirLS.Debugger.Server do end defp remove_paused_process(state = %__MODULE__{}, pid) do - {process = %PausedProcess{}, paused_processes} = Map.pop(state.paused_processes, pid) - true = Process.demonitor(process.ref, [:flush]) - %__MODULE__{state | paused_processes: paused_processes} + {process, paused_processes} = Map.pop(state.paused_processes, pid) + + if process do + true = Process.demonitor(process.ref, [:flush]) + end + + paused_processes end defp variables(state = %__MODULE__{}, pid, var, start, count, filter) do @@ -623,34 +781,39 @@ defmodule ElixirLS.Debugger.Server do end Enum.reduce(children, {state, []}, fn {name, value}, {state = %__MODULE__{}, result} -> - num_children = Variables.num_children(value) child_type = Variables.child_type(value) - - {state, var_id} = - if child_type do - ensure_var_id(state, pid, value) - else - {state, 0} - end - - json = %{ - "name" => to_string(name), - "value" => inspect(value), - "variablesReference" => var_id, - "type" => Variables.type(value) - } + {state, var_id} = get_variable_reference(child_type, state, pid, value) json = - case child_type do - :indexed -> Map.put(json, "indexedVariables", num_children) - :named -> Map.put(json, "namedVariables", num_children) - nil -> json - end + %{ + "name" => to_string(name), + "value" => inspect(value), + "variablesReference" => var_id + } + |> maybe_append_children_number(state.client_info, child_type, value) + |> maybe_append_variable_type(state.client_info, value) {state, result ++ [json]} end) end + defp get_variable_reference(nil, state, _pid, _value), do: {state, 0} + + defp get_variable_reference(_child_type, state, pid, value), + do: ensure_var_id(state, pid, value) + + defp maybe_append_children_number(map, %{"supportsVariablePaging" => true}, atom, value) + when atom in [:indexed, :named], + do: Map.put(map, Atom.to_string(atom) <> "Variables", Variables.num_children(value)) + + defp maybe_append_children_number(map, _, _, _value), do: map + + defp maybe_append_variable_type(map, %{"supportsVariableType" => true}, value) do + Map.put(map, "type", Variables.type(value)) + end + + defp maybe_append_variable_type(map, _, _value), do: map + defp evaluate_code_expression(expr, bindings, timeout) do task = Task.async(fn -> @@ -672,16 +835,21 @@ defmodule ElixirLS.Debugger.Server do result = Task.yield(task, timeout) || Task.shutdown(task) case result do - {:ok, data} -> data + {:ok, data} -> {:ok, data} nil -> :elixir_ls_expression_timeout _otherwise -> result end end - defp all_variables(paused_processes) do + defp all_variables(paused_processes, nil) do paused_processes - |> Enum.flat_map(fn {_pid, %PausedProcess{} = paused_process} -> - paused_process.frames |> Map.values() + |> Enum.flat_map(fn + {:evaluator, _} -> + # TODO setVariable? + [] + + {_pid, %PausedProcess{} = paused_process} -> + paused_process.frames |> Map.values() end) |> Enum.filter(&match?(%Frame{bindings: bindings} when is_map(bindings), &1)) |> Enum.flat_map(fn %Frame{bindings: bindings} -> @@ -689,23 +857,37 @@ defmodule ElixirLS.Debugger.Server do end) end + defp all_variables(paused_processes, frame_id) do + case find_frame(paused_processes, frame_id) do + {_pid, %Frame{bindings: bindings}} when is_map(bindings) -> + Binding.to_elixir_variable_names(bindings) + + _ -> + [] + end + end + defp find_var(paused_processes, var_id) do - Enum.find_value(paused_processes, fn {pid, %PausedProcess{} = paused_process} -> - if Map.has_key?(paused_process.vars, var_id) do - {pid, paused_process.vars[var_id]} + Enum.find_value(paused_processes, fn {pid, %{vars: vars}} -> + if Map.has_key?(vars, var_id) do + {pid, vars[var_id]} end end) end defp find_frame(paused_processes, frame_id) do - Enum.find_value(paused_processes, fn {pid, %PausedProcess{} = paused_process} -> - if Map.has_key?(paused_process.frames, frame_id) do - {pid, paused_process.frames[frame_id]} - end + Enum.find_value(paused_processes, fn + {pid, %{frames: frames}} -> + if Map.has_key?(frames, frame_id) do + {pid, frames[frame_id]} + end + + {:evaluator, _} -> + nil end) end - defp ensure_thread_id(state = %__MODULE__{}, pid) do + defp ensure_thread_id(state = %__MODULE__{}, pid) when is_pid(pid) do if Map.has_key?(state.threads_inverse, pid) do {state, state.threads_inverse[pid]} else @@ -724,7 +906,7 @@ defmodule ElixirLS.Debugger.Server do end) end - defp ensure_var_id(state = %__MODULE__{}, pid, var) do + defp ensure_var_id(state = %__MODULE__{}, pid, var) when is_pid(pid) or pid == :evaluator do unless Map.has_key?(state.paused_processes, pid) do raise ArgumentError, message: "paused process #{inspect(pid)} not found" end @@ -747,7 +929,7 @@ defmodule ElixirLS.Debugger.Server do end) end - defp ensure_frame_id(state = %__MODULE__{}, pid, %Frame{} = frame) do + defp ensure_frame_id(state = %__MODULE__{}, pid, %Frame{} = frame) when is_pid(pid) do unless Map.has_key?(state.paused_processes, pid) do raise ArgumentError, message: "paused process #{inspect(pid)} not found" end @@ -775,11 +957,11 @@ defmodule ElixirLS.Debugger.Server do File.cd!(project_dir) # Mixfile may already be loaded depending on cwd when launching debugger task - mixfile = Path.absname(System.get_env("MIX_EXS") || "mix.exs") + mixfile = Path.absname(MixfileHelpers.mix_exs()) # FIXME: Private API unless match?(%{file: ^mixfile}, Mix.ProjectStack.peek()) do - Code.compile_file(System.get_env("MIX_EXS") || "mix.exs") + Code.compile_file(MixfileHelpers.mix_exs()) end task = task || Mix.Project.config()[:default_task] @@ -791,7 +973,7 @@ defmodule ElixirLS.Debugger.Server do unless is_list(task_args) and "--no-compile" in task_args do case Mix.Task.run("compile", ["--ignore-module-conflict"]) do {:error, _} -> - IO.puts(:standard_error, "Aborting debugger due to compile errors") + Output.debugger_important("Aborting debugger due to compile errors") :init.stop(1) _ -> @@ -839,7 +1021,7 @@ defmodule ElixirLS.Debugger.Server do defp set_stack_trace_mode(nil), do: nil defp set_stack_trace_mode(_) do - IO.warn(~S(stackTraceMode must be "all", "no_tail", or "false")) + Output.debugger_important(~S(stackTraceMode must be "all", "no_tail", or "false")) end defp capabilities do @@ -849,14 +1031,14 @@ defmodule ElixirLS.Debugger.Server do "supportsConditionalBreakpoints" => true, "supportsHitConditionalBreakpoints" => true, "supportsLogPoints" => true, - "supportsEvaluateForHovers" => false, "exceptionBreakpointFilters" => [], "supportsStepBack" => false, "supportsSetVariable" => false, "supportsRestartFrame" => false, "supportsGotoTargetsRequest" => false, "supportsStepInTargetsRequest" => false, - "supportsCompletionsRequest" => false, + "supportsCompletionsRequest" => true, + "completionTriggerCharacters" => [".", "&", "%", "^", ":", "!", "-", "~"], "supportsModulesRequest" => false, "additionalModuleColumns" => [], "supportedChecksumAlgorithms" => [], @@ -864,6 +1046,10 @@ defmodule ElixirLS.Debugger.Server do "supportsExceptionOptions" => false, "supportsValueFormattingOptions" => false, "supportsExceptionInfoRequest" => false, + "supportsTerminateThreadsRequest" => true, + "supportsSingleThreadExecutionRequests" => true, + "supportsEvaluateForHovers" => true, + "supportsClipboardContext" => true, "supportTerminateDebuggee" => false } end @@ -965,8 +1151,7 @@ defmodule ElixirLS.Debugger.Server do [regex] {:error, error} -> - IO.puts( - :standard_error, + Output.debugger_important( "Unable to compile file pattern (#{inspect(pattern)}) into a regex. Received error: #{inspect(error)}" ) @@ -1041,7 +1226,7 @@ defmodule ElixirLS.Debugger.Server do {:module, _} = :int.ni(mod) catch _, _ -> - IO.warn( + Output.debugger_important( "Module #{inspect(mod)} cannot be interpreted. Consider adding it to `excludeModules`." ) end @@ -1067,7 +1252,7 @@ defmodule ElixirLS.Debugger.Server do end {:error, reason} -> - IO.warn( + Output.debugger_important( "Unable to set condition on a breakpoint in #{module}:#{inspect(lines)}: #{inspect(reason)}" ) end @@ -1081,7 +1266,7 @@ defmodule ElixirLS.Debugger.Server do condition {:error, reason} -> - IO.warn("Cannot parse breakpoint condition: #{inspect(reason)}") + Output.debugger_important("Cannot parse breakpoint condition: #{inspect(reason)}") "true" end end @@ -1095,13 +1280,21 @@ defmodule ElixirLS.Debugger.Server do if is_integer(term) do term else - IO.warn("Hit condition must evaluate to integer") + Output.debugger_important("Hit condition must evaluate to integer") 0 end catch kind, error -> - IO.warn("Error while evaluating hit condition: " <> Exception.format_banner(kind, error)) + Output.debugger_important( + "Error while evaluating hit condition: " <> Exception.format_banner(kind, error) + ) + 0 end end + + defp build_attach_mfa(reason) do + server = Process.info(self())[:registered_name] || self() + {__MODULE__, reason, [server]} + end end diff --git a/apps/elixir_ls_debugger/lib/debugger/stacktrace.ex b/apps/elixir_ls_debugger/lib/debugger/stacktrace.ex index a5a887364..549a5b3da 100644 --- a/apps/elixir_ls_debugger/lib/debugger/stacktrace.ex +++ b/apps/elixir_ls_debugger/lib/debugger/stacktrace.ex @@ -2,9 +2,10 @@ defmodule ElixirLS.Debugger.Stacktrace do @moduledoc """ Retrieves the stack trace for a process that's paused at a breakpoint """ + alias ElixirLS.Debugger.Output defmodule Frame do - defstruct [:level, :file, :module, :function, :args, :line, :bindings] + defstruct [:level, :file, :module, :function, :args, :line, :bindings, :messages] def name(%__MODULE__{} = frame) do "#{inspect(frame.module)}.#{frame.function}/#{Enum.count(frame.args)}" @@ -17,6 +18,8 @@ defmodule ElixirLS.Debugger.Stacktrace do [{level, {module, function, args}} | backtrace_rest] = :int.meta(meta_pid, :backtrace, :all) + messages = :int.meta(meta_pid, :messages) + first_frame = %Frame{ level: level, module: module, @@ -24,7 +27,8 @@ defmodule ElixirLS.Debugger.Stacktrace do args: args, file: get_file(module), line: break_line(pid), - bindings: get_bindings(meta_pid, level) + bindings: get_bindings(meta_pid, level), + messages: messages } # If backtrace_rest is empty, calling stack_frames causes an exception @@ -44,7 +48,8 @@ defmodule ElixirLS.Debugger.Stacktrace do args: args, file: get_file(mod), line: line, - bindings: Enum.into(bindings, %{}) + bindings: Enum.into(bindings, %{}), + messages: messages } end end @@ -52,7 +57,10 @@ defmodule ElixirLS.Debugger.Stacktrace do [first_frame | other_frames] error -> - IO.warn("Failed to obtain meta pid for #{inspect(pid)}: #{inspect(error)}") + Output.debugger_important( + "Failed to obtain meta for pid #{inspect(pid)}: #{inspect(error)}" + ) + [] end end diff --git a/apps/elixir_ls_debugger/lib/debugger/utils.ex b/apps/elixir_ls_debugger/lib/debugger/utils.ex index 742e07d73..d7bdc7916 100644 --- a/apps/elixir_ls_debugger/lib/debugger/utils.ex +++ b/apps/elixir_ls_debugger/lib/debugger/utils.ex @@ -18,4 +18,32 @@ defmodule ElixirLS.Debugger.Utils do {:error, "cannot parse MFA"} end end + + defp characters_to_binary!(binary, from, to) do + case :unicode.characters_to_binary(binary, from, to) do + result when is_binary(result) -> result + end + end + + def dap_character_to_elixir(_utf8_line, dap_character) when dap_character <= 0, do: 0 + + def dap_character_to_elixir(utf8_line, dap_character) do + utf16_line = + utf8_line + |> characters_to_binary!(:utf8, :utf16) + + byte_size = byte_size(utf16_line) + + utf8_character = + utf16_line + |> (&binary_part( + &1, + 0, + min(dap_character * 2, byte_size) + )).() + |> characters_to_binary!(:utf16, :utf8) + |> String.length() + + utf8_character + end end diff --git a/apps/elixir_ls_debugger/lib/debugger/variables.ex b/apps/elixir_ls_debugger/lib/debugger/variables.ex index e55614b31..f86a4309a 100644 --- a/apps/elixir_ls_debugger/lib/debugger/variables.ex +++ b/apps/elixir_ls_debugger/lib/debugger/variables.ex @@ -7,16 +7,47 @@ defmodule ElixirLS.Debugger.Variables do def child_type(var) when is_map(var), do: :named def child_type(var) when is_bitstring(var), do: :indexed def child_type(var) when is_tuple(var), do: :indexed - def child_type(var) when is_list(var), do: :indexed + + def child_type(var) when is_list(var) do + if Keyword.keyword?(var) do + :named + else + :indexed + end + end + + def child_type(var) when is_function(var), do: :named + + def child_type(var) when is_pid(var) do + case :erlang.process_info(var) do + :undefined -> :indexed + _results -> :named + end + end + + def child_type(var) when is_port(var) do + case :erlang.port_info(var) do + :undefined -> :indexed + _results -> :named + end + end + def child_type(_var), do: nil def children(var, start, count) when is_list(var) do start = start || 0 count = count || Enum.count(var) - var - |> Enum.slice(start, count) - |> with_index_as_name(start) + sliced = + var + |> Enum.slice(start, count) + + if Keyword.keyword?(var) do + sliced + else + sliced + |> with_index_as_name(start) + end end def children(var, start, count) when is_tuple(var) do @@ -49,6 +80,27 @@ defmodule ElixirLS.Debugger.Variables do end end + def children(var, start, count) when is_function(var) do + :erlang.fun_info(var) + |> children(start, count) + end + + def children(var, start, count) when is_pid(var) do + case :erlang.process_info(var) do + :undefined -> ["process is not alive"] + results -> results + end + |> children(start, count) + end + + def children(var, start, count) when is_port(var) do + case :erlang.port_info(var) do + :undefined -> ["port is not open"] + results -> results + end + |> children(start, count) + end + def children(_var, _start, _count) do [] end @@ -69,6 +121,25 @@ defmodule ElixirLS.Debugger.Variables do map_size(var) end + def num_children(var) when is_function(var) do + :erlang.fun_info(var) + |> Enum.count() + end + + def num_children(var) when is_pid(var) do + case :erlang.process_info(var) do + :undefined -> 1 + results -> results |> Enum.count() + end + end + + def num_children(var) when is_port(var) do + case :erlang.port_info(var) do + :undefined -> 1 + results -> results |> Enum.count() + end + end + def num_children(_var) do 0 end @@ -90,7 +161,14 @@ defmodule ElixirLS.Debugger.Variables do def type(var) when is_float(var), do: "float" def type(var) when is_function(var), do: "function" def type(var) when is_integer(var), do: "integer" - def type(var) when is_list(var), do: "list" + + def type(var) when is_list(var) do + if Keyword.keyword?(var) and var != [] do + "keyword" + else + "list" + end + end def type(%name{}), do: "%#{inspect(name)}{}" diff --git a/apps/elixir_ls_debugger/mix.exs b/apps/elixir_ls_debugger/mix.exs index 034c0a154..ab339fe38 100644 --- a/apps/elixir_ls_debugger/mix.exs +++ b/apps/elixir_ls_debugger/mix.exs @@ -4,12 +4,12 @@ defmodule ElixirLS.Debugger.Mixfile do def project do [ app: :elixir_ls_debugger, - version: "0.9.0", + version: "0.12.0", build_path: "../../_build", config_path: "../../config/config.exs", deps_path: "../../deps", lockfile: "../../mix.lock", - elixir: ">= 1.10.0", + elixir: ">= 1.12.3", build_embedded: false, start_permanent: true, build_per_environment: false, @@ -20,14 +20,14 @@ defmodule ElixirLS.Debugger.Mixfile do end def application do - [mod: {ElixirLS.Debugger, []}, extra_applications: [:mix, :logger]] + [mod: {ElixirLS.Debugger, []}, extra_applications: [:mix]] end defp deps do [ {:elixir_sense, github: "elixir-lsp/elixir_sense"}, {:elixir_ls_utils, in_umbrella: true}, - {:dialyxir, "~> 1.0", runtime: false} + {:dialyxir_vendored, github: "elixir-lsp/dialyxir", branch: "vendored", runtime: false} ] end end diff --git a/apps/elixir_ls_debugger/test/debugger_test.exs b/apps/elixir_ls_debugger/test/debugger_test.exs index fd1a87619..5c2a52d4b 100644 --- a/apps/elixir_ls_debugger/test/debugger_test.exs +++ b/apps/elixir_ls_debugger/test/debugger_test.exs @@ -29,71 +29,100 @@ defmodule ElixirLS.Debugger.ServerTest do describe "initialize" do test "succeeds", %{server: server} do - Server.receive_packet(server, initialize_req(1, %{"clientID" => "some_id"})) - assert_receive(response(_, 1, "initialize", %{"supportsConfigurationDoneRequest" => true})) - assert :sys.get_state(server).client_info == %{"clientID" => "some_id"} + in_fixture(__DIR__, "mix_project", fn -> + Server.receive_packet(server, initialize_req(1, %{"clientID" => "some_id"})) + + assert_receive( + response(_, 1, "initialize", %{"supportsConfigurationDoneRequest" => true}) + ) + + assert :sys.get_state(server).client_info == %{"clientID" => "some_id"} + end) end test "fails when already initialized", %{server: server} do - Server.receive_packet(server, initialize_req(1, %{"clientID" => "some_id"})) - assert_receive(response(_, 1, "initialize", %{"supportsConfigurationDoneRequest" => true})) - Server.receive_packet(server, initialize_req(2, %{"clientID" => "some_id"})) + in_fixture(__DIR__, "mix_project", fn -> + Server.receive_packet(server, initialize_req(1, %{"clientID" => "some_id"})) - assert_receive( - error_response( - _, - 2, - "initialize", - "invalidRequest", - "Debugger request {command} was not expected", - %{"command" => "initialize"} + assert_receive( + response(_, 1, "initialize", %{"supportsConfigurationDoneRequest" => true}) ) - ) + + Server.receive_packet(server, initialize_req(2, %{"clientID" => "some_id"})) + + assert_receive( + error_response( + _, + 2, + "initialize", + "invalidRequest", + "Debugger request {command} was not expected", + %{"command" => "initialize"} + ) + ) + end) end test "rejects requests when not initialized", %{server: server} do - Server.receive_packet( - server, - set_breakpoints_req(1, %{"path" => "lib/mix_project.ex"}, [%{"line" => 3}]) - ) + in_fixture(__DIR__, "mix_project", fn -> + Server.receive_packet( + server, + set_breakpoints_req(1, %{"path" => "lib/mix_project.ex"}, [%{"line" => 3}]) + ) - assert_receive( - error_response( - _, - 1, - "setBreakpoints", - "invalidRequest", - "Debugger request {command} was not expected", - %{"command" => "setBreakpoints"} + assert_receive( + error_response( + _, + 1, + "setBreakpoints", + "invalidRequest", + "Debugger request {command} was not expected", + %{"command" => "setBreakpoints"} + ) ) - ) + end) end end describe "disconnect" do test "succeeds when not initialized", %{server: server} do - Process.flag(:trap_exit, true) - Server.receive_packet(server, request(1, "disconnect")) - assert_receive(response(_, 1, "disconnect", %{})) - assert_receive({:EXIT, ^server, {:exit_code, 0}}) - Process.flag(:trap_exit, false) + in_fixture(__DIR__, "mix_project", fn -> + Process.flag(:trap_exit, true) + Server.receive_packet(server, request(1, "disconnect")) + assert_receive(response(_, 1, "disconnect", %{})) + assert_receive({:EXIT, ^server, {:exit_code, 0}}) + Process.flag(:trap_exit, false) + end) end test "succeeds when initialized", %{server: server} do - Process.flag(:trap_exit, true) - Server.receive_packet(server, initialize_req(1, %{"clientID" => "some_id"})) - assert_receive(response(_, 1, "initialize", %{"supportsConfigurationDoneRequest" => true})) - Server.receive_packet(server, request(2, "disconnect")) - assert_receive(response(_, 2, "disconnect", %{})) - assert_receive({:EXIT, ^server, {:exit_code, 0}}) - Process.flag(:trap_exit, false) + in_fixture(__DIR__, "mix_project", fn -> + Process.flag(:trap_exit, true) + Server.receive_packet(server, initialize_req(1, %{"clientID" => "some_id"})) + + assert_receive( + response(_, 1, "initialize", %{"supportsConfigurationDoneRequest" => true}) + ) + + Server.receive_packet(server, request(2, "disconnect")) + assert_receive(response(_, 2, "disconnect", %{})) + assert_receive({:EXIT, ^server, {:exit_code, 0}}) + Process.flag(:trap_exit, false) + end) end end @tag :fixture test "basic debugging", %{server: server} do in_fixture(__DIR__, "mix_project", fn -> - Server.receive_packet(server, initialize_req(1, %{})) + Server.receive_packet( + server, + initialize_req(1, %{ + "supportsVariablePaging" => true, + "supportsVariableType" => true + }) + ) + assert_receive(response(_, 1, "initialize", %{"supportsConfigurationDoneRequest" => true})) Server.receive_packet( @@ -164,6 +193,13 @@ defmodule ElixirLS.Debugger.ServerTest do "namedVariables" => 1, "variablesReference" => vars_id }, + %{ + "expensive" => false, + "indexedVariables" => 0, + "name" => "process info", + "namedVariables" => _, + "variablesReference" => _ + }, %{ "expensive" => false, "indexedVariables" => 1, @@ -189,7 +225,7 @@ defmodule ElixirLS.Debugger.ServerTest do 1000 Server.receive_packet(server, continue_req(10, thread_id)) - assert_receive response(_, 10, "continue", %{"allThreadsContinued" => false}) + assert_receive response(_, 10, "continue", %{"allThreadsContinued" => true}) end) end @@ -326,7 +362,7 @@ defmodule ElixirLS.Debugger.ServerTest do ) Server.receive_packet(server, continue_req(15, thread_id)) - assert_receive response(_, 15, "continue", %{"allThreadsContinued" => false}) + assert_receive response(_, 15, "continue", %{"allThreadsContinued" => true}) Server.receive_packet(server, stacktrace_req(7, thread_id)) thread_id_str = inspect(thread_id) @@ -388,7 +424,7 @@ defmodule ElixirLS.Debugger.ServerTest do }), 500 - {log, stderr} = + {log, _stderr} = capture_log_and_io(:standard_error, fn -> assert_receive event(_, "thread", %{ "reason" => "exited", @@ -398,7 +434,6 @@ defmodule ElixirLS.Debugger.ServerTest do end) assert log =~ "Fixture MixProject expected error" - assert stderr =~ "Fixture MixProject expected error" end) end @@ -447,7 +482,7 @@ defmodule ElixirLS.Debugger.ServerTest do }), 5000 - {log, io} = + {log, _io} = capture_log_and_io(:stderr, fn -> assert_receive event(_, "thread", %{ "reason" => "exited", @@ -457,7 +492,6 @@ defmodule ElixirLS.Debugger.ServerTest do end) assert log =~ "Fixture MixProject raise for exit_self/0" - assert io =~ "Fixture MixProject raise for exit_self/0" assert_receive event(_, "exited", %{ "exitCode" => 1 @@ -469,6 +503,149 @@ defmodule ElixirLS.Debugger.ServerTest do end) end + @tag :fixture + test "terminate threads", %{server: server} do + in_fixture(__DIR__, "mix_project", fn -> + Server.receive_packet(server, initialize_req(1, %{})) + assert_receive(response(_, 1, "initialize", %{"supportsConfigurationDoneRequest" => true})) + + Server.receive_packet( + server, + launch_req(2, %{ + "request" => "launch", + "type" => "mix_task", + "task" => "run", + "taskArgs" => ["-e", "MixProject.Some.sleep()"], + "projectDir" => File.cwd!() + }) + ) + + assert_receive(response(_, 2, "launch", %{}), 5000) + assert_receive(event(_, "initialized", %{})) + + Server.receive_packet(server, request(5, "configurationDone", %{})) + assert_receive(response(_, 5, "configurationDone", %{})) + Process.sleep(1000) + Server.receive_packet(server, request(6, "threads", %{})) + assert_receive(response(_, 6, "threads", %{"threads" => threads}), 1_000) + + assert [thread_id] = + threads + |> Enum.filter(&(&1["name"] |> String.starts_with?("MixProject.Some"))) + |> Enum.map(& &1["id"]) + + Server.receive_packet(server, request(7, "terminateThreads", %{"threadIds" => [thread_id]})) + assert_receive(response(_, 7, "terminateThreads", %{}), 500) + + assert_receive event(_, "thread", %{ + "reason" => "exited", + "threadId" => ^thread_id + }), + 5000 + end) + end + + describe "pause" do + @tag :fixture + test "alive", %{server: server} do + in_fixture(__DIR__, "mix_project", fn -> + Server.receive_packet(server, initialize_req(1, %{})) + + assert_receive( + response(_, 1, "initialize", %{"supportsConfigurationDoneRequest" => true}) + ) + + Server.receive_packet( + server, + launch_req(2, %{ + "request" => "launch", + "type" => "mix_task", + "task" => "run", + "taskArgs" => ["-e", "MixProject.Some.sleep()"], + "projectDir" => File.cwd!() + }) + ) + + assert_receive(response(_, 2, "launch", %{}), 5000) + assert_receive(event(_, "initialized", %{})) + + Server.receive_packet(server, request(5, "configurationDone", %{})) + assert_receive(response(_, 5, "configurationDone", %{})) + Process.sleep(1000) + Server.receive_packet(server, request(6, "threads", %{})) + assert_receive(response(_, 6, "threads", %{"threads" => threads}), 1_000) + + assert [thread_id] = + threads + |> Enum.filter(&(&1["name"] |> String.starts_with?("MixProject.Some"))) + |> Enum.map(& &1["id"]) + + Server.receive_packet(server, request(7, "pause", %{"threadId" => thread_id})) + assert_receive(response(_, 7, "pause", %{}), 500) + + assert_receive event(_, "stopped", %{ + "allThreadsStopped" => false, + "reason" => "pause", + "threadId" => ^thread_id + }), + 500 + + assert_receive event(_, "output", %{ + "category" => "important", + "output" => "Failed to obtain meta for pid" <> _ + }) + end) + end + + @tag :fixture + test "dead", %{server: server} do + in_fixture(__DIR__, "mix_project", fn -> + Server.receive_packet(server, initialize_req(1, %{})) + + assert_receive( + response(_, 1, "initialize", %{"supportsConfigurationDoneRequest" => true}) + ) + + Server.receive_packet( + server, + launch_req(2, %{ + "request" => "launch", + "type" => "mix_task", + "task" => "run", + "taskArgs" => ["-e", "MixProject.Some.sleep()"], + "projectDir" => File.cwd!() + }) + ) + + assert_receive(response(_, 2, "launch", %{}), 5000) + assert_receive(event(_, "initialized", %{})) + + Server.receive_packet(server, request(5, "configurationDone", %{})) + assert_receive(response(_, 5, "configurationDone", %{})) + Process.sleep(1000) + Server.receive_packet(server, request(6, "threads", %{})) + assert_receive(response(_, 6, "threads", %{"threads" => threads}), 1_000) + + assert [thread_id] = + threads + |> Enum.filter(&(&1["name"] |> String.starts_with?("MixProject.Some"))) + |> Enum.map(& &1["id"]) + + Process.whereis(MixProject.Some) |> Process.exit(:kill) + Process.sleep(1000) + + Server.receive_packet(server, request(7, "pause", %{"threadId" => thread_id})) + assert_receive(response(_, 7, "pause", %{}), 500) + + assert_receive event(_, "thread", %{ + "reason" => "exited", + "threadId" => ^thread_id + }), + 5000 + end) + end + end + describe "breakpoints" do @tag :fixture test "sets and unsets breakpoints in erlang modules", %{server: server} do @@ -1304,95 +1481,129 @@ defmodule ElixirLS.Debugger.ServerTest do end test "Evaluate expression with OK result", %{server: server} do - Server.receive_packet(server, initialize_req(1, %{})) - assert_receive(response(_, 1, "initialize", _)) + in_fixture(__DIR__, "mix_project", fn -> + Server.receive_packet(server, initialize_req(1, %{})) + assert_receive(response(_, 1, "initialize", _)) - Server.receive_packet( - server, - gen_watch_expression_packet("1 + 2 + 3 + 4") - ) + Server.receive_packet( + server, + gen_watch_expression_packet("1 + 2 + 3 + 4") + ) - assert_receive(%{"body" => %{"result" => "10"}}, 1000) + assert_receive(%{"body" => %{"result" => "10"}}, 1000) - assert Process.alive?(server) + assert Process.alive?(server) + end) end @tag :capture_log test "Evaluate expression with ERROR result", %{server: server} do - Server.receive_packet(server, initialize_req(1, %{})) - assert_receive(response(_, 1, "initialize", _)) + in_fixture(__DIR__, "mix_project", fn -> + Server.receive_packet(server, initialize_req(1, %{})) + assert_receive(response(_, 1, "initialize", _)) - Server.receive_packet( - server, - gen_watch_expression_packet("1 = 2") - ) + Server.receive_packet( + server, + gen_watch_expression_packet("1 = 2") + ) - assert_receive(%{"body" => %{"result" => result}}, 1000) + assert_receive(%{"body" => %{"result" => result}}, 1000) - assert result =~ ~r/badmatch/ + assert result =~ ~r/badmatch/ - assert Process.alive?(server) + assert Process.alive?(server) + end) end test "Evaluate expression with attempt to exit debugger process", %{server: server} do - Server.receive_packet(server, initialize_req(1, %{})) - assert_receive(response(_, 1, "initialize", _)) + in_fixture(__DIR__, "mix_project", fn -> + Server.receive_packet(server, initialize_req(1, %{})) + assert_receive(response(_, 1, "initialize", _)) - Server.receive_packet( - server, - gen_watch_expression_packet("Process.exit(self(), :normal)") - ) + Server.receive_packet( + server, + gen_watch_expression_packet("Process.exit(self(), :normal)") + ) - assert_receive(%{"body" => %{"result" => result}}, 1000) + assert_receive(%{"body" => %{"result" => result}}, 1000) - assert result =~ ~r/:exit/ + assert result =~ ~r/:exit/ - assert Process.alive?(server) + assert Process.alive?(server) + end) end test "Evaluate expression with attempt to throw debugger process", %{server: server} do - Server.receive_packet(server, initialize_req(1, %{})) - assert_receive(response(_, 1, "initialize", _)) + in_fixture(__DIR__, "mix_project", fn -> + Server.receive_packet(server, initialize_req(1, %{})) + assert_receive(response(_, 1, "initialize", _)) - Server.receive_packet( - server, - gen_watch_expression_packet("throw(:goodmorning_bug)") - ) + Server.receive_packet( + server, + gen_watch_expression_packet("throw(:goodmorning_bug)") + ) - assert_receive(%{"body" => %{"result" => result}}, 1000) + assert_receive(%{"body" => %{"result" => result}}, 1000) - assert result =~ ~r/:goodmorning_bug/ + assert result =~ ~r/:goodmorning_bug/ - assert Process.alive?(server) + assert Process.alive?(server) + end) end test "Evaluate expression which has long execution", %{server: server} do - Server.receive_packet(server, initialize_req(1, %{})) - assert_receive(response(_, 1, "initialize", _)) + in_fixture(__DIR__, "mix_project", fn -> + Server.receive_packet(server, initialize_req(1, %{})) + assert_receive(response(_, 1, "initialize", _)) - Server.receive_packet( - server, - launch_req(2, %{ - "request" => "launch", - "type" => "mix_task", - "task" => "test", - "projectDir" => File.cwd!(), - "debugExpressionTimeoutMs" => 500 - }) - ) + Server.receive_packet( + server, + launch_req(2, %{ + "request" => "launch", + "type" => "mix_task", + "task" => "test", + "projectDir" => File.cwd!(), + "debugExpressionTimeoutMs" => 500 + }) + ) - assert_receive(response(_, 2, "launch", %{}), 5000) + assert_receive(response(_, 2, "launch", %{}), 5000) + + Server.receive_packet( + server, + gen_watch_expression_packet(":timer.sleep(10_000)") + ) + + assert_receive(%{"body" => %{"result" => result}}, 1100) + + assert result =~ ~r/:elixir_ls_expression_timeout/ + + assert Process.alive?(server) + end) + end + end + + test "Completions", %{server: server} do + in_fixture(__DIR__, "mix_project", fn -> + Server.receive_packet(server, initialize_req(1, %{})) + assert_receive(response(_, 1, "initialize", _)) Server.receive_packet( server, - gen_watch_expression_packet(":timer.sleep(10_000)") + %{ + "arguments" => %{ + "text" => "DateTi", + "column" => 7 + }, + "command" => "completions", + "seq" => 1, + "type" => "request" + } ) - assert_receive(%{"body" => %{"result" => result}}, 1100) - - assert result =~ ~r/:elixir_ls_expression_timeout/ + assert_receive(%{"body" => %{"targets" => _targets}}, 10000) assert Process.alive?(server) - end + end) end end diff --git a/apps/elixir_ls_debugger/test/fixtures/mix_project/lib/mix_project.ex b/apps/elixir_ls_debugger/test/fixtures/mix_project/lib/mix_project.ex index 5bb02e70b..9e3423b35 100644 --- a/apps/elixir_ls_debugger/test/fixtures/mix_project/lib/mix_project.ex +++ b/apps/elixir_ls_debugger/test/fixtures/mix_project/lib/mix_project.ex @@ -38,4 +38,9 @@ defmodule MixProject.Some do def quadruple(x) do double(double(x)) end + + def sleep do + Supervisor.start_link([], strategy: :one_for_one, name: __MODULE__) + Process.sleep(:infinity) + end end diff --git a/apps/elixir_ls_debugger/test/test_helper.exs b/apps/elixir_ls_debugger/test/test_helper.exs index eb3261d41..1650a4495 100644 --- a/apps/elixir_ls_debugger/test/test_helper.exs +++ b/apps/elixir_ls_debugger/test/test_helper.exs @@ -1,6 +1,2 @@ -if Version.match?(System.version(), ">= 1.11.0") do - Code.put_compiler_option(:warnings_as_errors, true) -end - Application.put_env(:elixir_ls_debugger, :test_mode, true) ExUnit.start(exclude: [pending: true]) diff --git a/apps/elixir_ls_debugger/test/utils_test.exs b/apps/elixir_ls_debugger/test/utils_test.exs index 4f91eb69c..bb3e8fc89 100644 --- a/apps/elixir_ls_debugger/test/utils_test.exs +++ b/apps/elixir_ls_debugger/test/utils_test.exs @@ -33,4 +33,34 @@ defmodule ElixirLS.Debugger.UtilsTest do assert {:error, "cannot parse MFA"} == Utils.parse_mfa("") end end + + describe "positions" do + test "dap_character_to_elixir empty" do + assert 0 == Utils.dap_character_to_elixir("", 0) + end + + test "dap_character_to_elixir empty after end" do + assert 0 == Utils.dap_character_to_elixir("", 1) + end + + test "dap_character_to_elixir first char" do + assert 0 == Utils.dap_character_to_elixir("abcde", 0) + end + + test "dap_character_to_elixir line" do + assert 1 == Utils.dap_character_to_elixir("abcde", 1) + end + + test "dap_character_to_elixir before line start" do + assert 0 == Utils.dap_character_to_elixir("abcde", -1) + end + + test "dap_character_to_elixir after line end" do + assert 5 == Utils.dap_character_to_elixir("abcde", 15) + end + + test "dap_character_to_elixir utf8" do + assert 1 == Utils.dap_character_to_elixir("🏳️‍🌈abcde", 6) + end + end end diff --git a/apps/elixir_ls_debugger/test/variables_test.exs b/apps/elixir_ls_debugger/test/variables_test.exs index f0b0b4a18..82d7150cf 100644 --- a/apps/elixir_ls_debugger/test/variables_test.exs +++ b/apps/elixir_ls_debugger/test/variables_test.exs @@ -39,6 +39,8 @@ defmodule ElixirLS.Debugger.VariablesTest do assert Variables.type([1]) == "list" assert Variables.type('asd') == "list" + assert Variables.type(abc: 123) == "keyword" + assert Variables.type(%{}) == "map" assert Variables.type(%{asd: 123}) == "map" assert Variables.type(%{"asd" => 123}) == "map" @@ -77,16 +79,21 @@ defmodule ElixirLS.Debugger.VariablesTest do assert Variables.num_children(:erlang.make_ref()) == 0 - assert Variables.num_children(fn -> :ok end) == 0 + # As of OTP 24 10 values but it's better not to hardcode that + assert Variables.num_children(fn -> :ok end) != 0 - assert Variables.num_children(spawn(fn -> :ok end)) == 0 + # As of OTP 24 16 values but it's better not to hardcode that + assert Variables.num_children(self()) != 0 - assert Variables.num_children(hd(:erlang.ports())) == 0 + # As of OTP 24 7 values but it's better not to hardcode that + assert Variables.num_children(hd(:erlang.ports())) != 0 assert Variables.num_children([]) == 0 assert Variables.num_children([1]) == 1 assert Variables.num_children('asd') == 3 + assert Variables.num_children(abc: 123) == 1 + assert Variables.num_children(%{}) == 0 assert Variables.num_children(%{asd: 123}) == 1 assert Variables.num_children(%{"asd" => 123}) == 1 @@ -104,6 +111,20 @@ defmodule ElixirLS.Debugger.VariablesTest do assert Variables.children('asd', 0, 10) == [{"0", 97}, {"1", 115}, {"2", 100}] end + test "keyword" do + assert Variables.children([abc: 123], 0, 10) == [abc: 123] + + assert Variables.children([abc1: 121, abc2: 122, abc3: 123, abc4: 124], 0, 2) == [ + abc1: 121, + abc2: 122 + ] + + assert Variables.children([abc1: 121, abc2: 122, abc3: 123, abc4: 124], 1, 2) == [ + abc2: 122, + abc3: 123 + ] + end + test "tuple" do assert Variables.children({}, 0, 10) == [] assert Variables.children({1}, 0, 10) == [{"0", 1}] @@ -155,5 +176,23 @@ defmodule ElixirLS.Debugger.VariablesTest do assert Variables.children(<<0::size(17)>>, 1, 10) == [{"1", 0}, {"2", <<0::size(1)>>}] assert Variables.children(<<0::size(17)>>, 1, 1) == [{"1", 0}] end + + test "fun" do + children = Variables.children(fn -> :ok end, 0, 10) + assert children[:module] == ElixirLS.Debugger.VariablesTest + assert children[:type] == :local + assert children[:arity] == 0 + end + + test "pid" do + children = Variables.children(self(), 0, 10) + assert children[:trap_exit] == false + assert children[:status] == :running + end + + test "port" do + children = Variables.children(hd(:erlang.ports()), 0, 10) + assert children[:name] == 'forker' + end end end diff --git a/apps/elixir_ls_utils/lib/launch.ex b/apps/elixir_ls_utils/lib/launch.ex index d5c8ae83a..ffc046ad6 100644 --- a/apps/elixir_ls_utils/lib/launch.ex +++ b/apps/elixir_ls_utils/lib/launch.ex @@ -13,14 +13,13 @@ defmodule ElixirLS.Utils.Launch do :ok end - def print_versions do - IO.inspect(System.build_info()[:build], label: "Elixir version") - IO.inspect(System.otp_release(), label: "Erlang version") - - IO.puts( - "ElixirLS compiled with Elixir #{@compiled_elixir_version}" <> - " and erlang #{@compiled_otp_version}" - ) + def get_versions do + %{ + current_elixir_version: inspect(System.build_info()[:build]), + current_otp_version: inspect(System.otp_release()), + compile_elixir_version: inspect(@compiled_elixir_version), + compile_otp_version: inspect(@compiled_otp_version) + } end def language_server_version do diff --git a/apps/elixir_ls_utils/lib/minimum_version.ex b/apps/elixir_ls_utils/lib/minimum_version.ex index e3fab9786..852c29ef1 100644 --- a/apps/elixir_ls_utils/lib/minimum_version.ex +++ b/apps/elixir_ls_utils/lib/minimum_version.ex @@ -11,11 +11,11 @@ defmodule ElixirLS.Utils.MinimumVersion do end def check_elixir_version do - if Version.match?(System.version(), ">= 1.10.0") do + if Version.match?(System.version(), ">= 1.12.3") do :ok else {:error, - "Elixir versions below 1.10 are not supported. (Currently running v#{System.version()})"} + "Elixir versions below 1.12.3 are not supported. (Currently running v#{System.version()})"} end end end diff --git a/apps/elixir_ls_utils/lib/mixfile_helpers.ex b/apps/elixir_ls_utils/lib/mixfile_helpers.ex new file mode 100644 index 000000000..d8425df5b --- /dev/null +++ b/apps/elixir_ls_utils/lib/mixfile_helpers.ex @@ -0,0 +1,5 @@ +defmodule ElixirLS.Utils.MixfileHelpers do + def mix_exs do + System.get_env("MIX_EXS") || "mix.exs" + end +end diff --git a/apps/elixir_ls_utils/lib/packet_stream.ex b/apps/elixir_ls_utils/lib/packet_stream.ex index 17017e414..1201f9834 100644 --- a/apps/elixir_ls_utils/lib/packet_stream.ex +++ b/apps/elixir_ls_utils/lib/packet_stream.ex @@ -31,7 +31,7 @@ defmodule ElixirLS.Utils.PacketStream do :ok {:error, reason} -> - IO.warn("Unable to read from device: #{inspect(reason)}") + raise "Unable to read from device: #{inspect(reason)}" end ) end diff --git a/apps/elixir_ls_utils/mix.exs b/apps/elixir_ls_utils/mix.exs index 52683fe8a..b6a01fcd4 100644 --- a/apps/elixir_ls_utils/mix.exs +++ b/apps/elixir_ls_utils/mix.exs @@ -4,13 +4,13 @@ defmodule ElixirLS.Utils.Mixfile do def project do [ app: :elixir_ls_utils, - version: "0.9.0", + version: "0.12.0", build_path: "../../_build", config_path: "../../config/config.exs", deps_path: "../../deps", elixirc_paths: elixirc_paths(Mix.env()), lockfile: "../../mix.lock", - elixir: ">= 1.10.0", + elixir: ">= 1.12.3", build_embedded: false, start_permanent: false, build_per_environment: false, @@ -29,7 +29,7 @@ defmodule ElixirLS.Utils.Mixfile do [ {:jason_vendored, github: "elixir-lsp/jason", branch: "vendored"}, {:mix_task_archive_deps, github: "elixir-lsp/mix_task_archive_deps"}, - {:dialyxir, "~> 1.0", runtime: false} + {:dialyxir_vendored, github: "elixir-lsp/dialyxir", branch: "vendored", runtime: false} ] end diff --git a/apps/elixir_ls_utils/priv/launch.sh b/apps/elixir_ls_utils/priv/launch.sh index d715d752a..0e8aeb5f9 100755 --- a/apps/elixir_ls_utils/priv/launch.sh +++ b/apps/elixir_ls_utils/priv/launch.sh @@ -41,7 +41,7 @@ fi # give them the chance here. ELS_MODE will be set for # the really complex stuff. Use an XDG compliant path. -els_setup="${XDG_CONFIG_HOME:-~/.config}/elixir_ls/setup.sh" +els_setup="${XDG_CONFIG_HOME:-$HOME/.config}/elixir_ls/setup.sh" if test -f "${els_setup}" then . "${els_setup}" diff --git a/apps/elixir_ls_utils/test/packet_stream_test.exs b/apps/elixir_ls_utils/test/packet_stream_test.exs index 15183718a..8e47ca7b7 100644 --- a/apps/elixir_ls_utils/test/packet_stream_test.exs +++ b/apps/elixir_ls_utils/test/packet_stream_test.exs @@ -2,7 +2,6 @@ defmodule ElixirLS.Utils.PacketStreamTest do use ExUnit.Case, async: true alias ElixirLS.Utils.PacketStream - import ExUnit.CaptureIO describe "content-type" do test "default mime" do @@ -123,11 +122,14 @@ defmodule ElixirLS.Utils.PacketStreamTest do {:ok, pid} = File.open("test/fixtures/protocol_messages/invalid_content_length", [:read, :binary]) - assert capture_io(:stderr, fn -> - assert [] = - PacketStream.stream(pid) - |> Enum.to_list() - end) =~ "Unable to read from device: :truncated" + error = + assert_raise RuntimeError, fn -> + assert [] = + PacketStream.stream(pid) + |> Enum.to_list() + end + + assert error.message =~ "Unable to read from device: :truncated" File.close(pid) end @@ -136,11 +138,14 @@ defmodule ElixirLS.Utils.PacketStreamTest do {:ok, pid} = File.open("test/fixtures/protocol_messages/invalid_content_type", [:read, :binary]) - assert capture_io(:stderr, fn -> - assert [] = - PacketStream.stream(pid) - |> Enum.to_list() - end) =~ "Unable to read from device: :not_supported_content_type" + error = + assert_raise RuntimeError, fn -> + assert [] = + PacketStream.stream(pid) + |> Enum.to_list() + end + + assert error.message =~ "Unable to read from device: :not_supported_content_type" File.close(pid) end @@ -149,11 +154,14 @@ defmodule ElixirLS.Utils.PacketStreamTest do for i <- 0..6 do {:ok, pid} = File.open("test/fixtures/protocol_messages/no_body_#{i}", [:read, :binary]) - capture_io(:stderr, fn -> + try do assert [] = PacketStream.stream(pid) |> Enum.to_list() - end) + rescue + RuntimeError -> + :ok + end File.close(pid) end @@ -162,11 +170,14 @@ defmodule ElixirLS.Utils.PacketStreamTest do test "invalid JSON" do {:ok, pid} = File.open("test/fixtures/protocol_messages/invalid_json", [:read, :binary]) - assert capture_io(:stderr, fn -> - assert [] = - PacketStream.stream(pid) - |> Enum.to_list() - end) =~ "Unable to read from device: %JasonVendored.DecodeError" + error = + assert_raise RuntimeError, fn -> + assert [] = + PacketStream.stream(pid) + |> Enum.to_list() + end + + assert error.message =~ "Unable to read from device: %JasonVendored.DecodeError" File.close(pid) end @@ -175,12 +186,15 @@ defmodule ElixirLS.Utils.PacketStreamTest do {:ok, pid} = File.open("test/fixtures/protocol_messages/invalid_after_valid", [:read, :binary]) - assert capture_io(:stderr, fn -> - # note that we halt the stream and discard any further valid messages - assert [%{"some" => "value"}] = - PacketStream.stream(pid) - |> Enum.to_list() - end) =~ "Unable to read from device: %JasonVendored.DecodeError" + error = + assert_raise RuntimeError, fn -> + # note that we halt the stream and discard any further valid messages + assert [%{"some" => "value"}] = + PacketStream.stream(pid) + |> Enum.to_list() + end + + assert error.message =~ "Unable to read from device: %JasonVendored.DecodeError" File.close(pid) end diff --git a/apps/elixir_ls_utils/test/support/mix_test.case.ex b/apps/elixir_ls_utils/test/support/mix_test.case.ex index be4f5a84f..0ec49921e 100644 --- a/apps/elixir_ls_utils/test/support/mix_test.case.ex +++ b/apps/elixir_ls_utils/test/support/mix_test.case.ex @@ -1,5 +1,6 @@ defmodule ElixirLS.Utils.MixTest.Case do # This module is based heavily on MixTest.Case in Elixir's tests + # https://github.com/elixir-lang/elixir/blob/db64b413a036c01c8e1cac8dd5e1c65107d90176/lib/mix/test/test_helper.exs#L29 use ExUnit.CaseTemplate using do @@ -8,32 +9,37 @@ defmodule ElixirLS.Utils.MixTest.Case do end end - setup config do - if apps = config[:apps] do - Logger.remove_backend(:console) - end + @apps Enum.map(Application.loaded_applications(), &elem(&1, 0)) + @allowed_apps ~w(docsh xmerl syntax_tools edoc elixir_sense elixir_ls_debugger elixir_ls_utils language_server stream_data)a + setup do on_exit(fn -> Application.start(:logger) + Mix.env(:dev) + Mix.target(:host) Mix.Task.clear() Mix.Shell.Process.flush() + Mix.State.clear_cache() + Mix.ProjectStack.clear_stack() delete_tmp_paths() - if apps do - for app <- apps do - Application.stop(app) - Application.unload(app) - end - - Logger.add_backend(:console, flush: true) + for {app, _, _} <- Application.loaded_applications(), + app not in @apps, + app not in @allowed_apps do + Application.stop(app) + Application.unload(app) end end) :ok end + def fixture_path(dir) do + Path.expand("fixtures", dir) + end + def fixture_path(dir, extension) do - Path.join(Path.expand("fixtures", dir), extension) + Path.join(fixture_path(dir), remove_colons(extension)) end def tmp_path do @@ -41,7 +47,13 @@ defmodule ElixirLS.Utils.MixTest.Case do end def tmp_path(extension) do - Path.join(tmp_path(), to_string(extension)) + Path.join(tmp_path(), remove_colons(extension)) + end + + defp remove_colons(term) do + term + |> to_string() + |> String.replace(":", "") end def purge(modules) do @@ -72,10 +84,6 @@ defmodule ElixirLS.Utils.MixTest.Case do get_path = :code.get_path() previous = :code.all_loaded() - project_stack = clear_project_stack!() - - ExUnit.CaptureLog.capture_log(fn -> Application.stop(:mix) end) - Application.start(:mix) try do File.cd!(dest, function) @@ -83,12 +91,10 @@ defmodule ElixirLS.Utils.MixTest.Case do :code.set_path(get_path) for {mod, file} <- :code.all_loaded() -- previous, - file == :in_memory or file == [] or (is_list(file) and :lists.prefix(flag, file)) do + file == [] or (is_list(file) and List.starts_with?(file, flag)) do mod end |> purge - - restore_project_stack!(project_stack) end end @@ -97,61 +103,6 @@ defmodule ElixirLS.Utils.MixTest.Case do for path <- :code.get_path(), :string.str(path, tmp) != 0, do: :code.del_path(path) end - defp clear_project_stack! do - stack = clear_project_stack!([]) - - clear_mix_cache() - - # Attempt to purge mixfiles for dependencies to avoid module redefinition warnings - mix_exs = System.get_env("MIX_EXS") || "mix.exs" - - for {mod, :in_memory} <- :code.all_loaded(), - source = mod.module_info[:compile][:source], - is_list(source), - String.ends_with?(to_string(source), mix_exs), - do: purge([mod]) - - stack - end - - defp clear_project_stack!(stack) do - # FIXME: Private API - case Mix.Project.pop() do - nil -> - stack - - project -> - clear_project_stack!([project | stack]) - end - end - - defp restore_project_stack!(stack) do - # FIXME: Private API - Mix.ProjectStack.clear_stack() - clear_mix_cache() - - for %{name: module, file: file} <- stack do - :code.purge(module) - :code.delete(module) - # It's important to use `compile_file` here instead of `require_file` - # because we are recompiling this file to reload the mix project back onto - # the project stack. - Code.compile_file(file) - end - end - - # FIXME: Private API - defp clear_mix_cache do - module = - if Version.match?(System.version(), ">= 1.10.0") do - Mix.State - else - Mix.ProjectStack - end - - module.clear_cache() - end - def capture_log_and_io(device, fun) when is_function(fun, 0) do # Logger gets stopped during some tests so restart it to be able to capture logs (and kept the # test output clean) diff --git a/apps/elixir_ls_utils/test/support/packet_capture.ex b/apps/elixir_ls_utils/test/support/packet_capture.ex index 24166754e..265447055 100644 --- a/apps/elixir_ls_utils/test/support/packet_capture.ex +++ b/apps/elixir_ls_utils/test/support/packet_capture.ex @@ -26,7 +26,6 @@ defmodule ElixirLS.Utils.PacketCapture do handle_output(to_string(chars), from, reply_as, parent) end - @impl GenServer def handle_info( {:io_request, from, reply_as, {:put_chars, _encoding, module, fun, args}}, parent diff --git a/apps/elixir_ls_utils/test/test_helper.exs b/apps/elixir_ls_utils/test/test_helper.exs index c8a7de607..3c96d9e7a 100644 --- a/apps/elixir_ls_utils/test/test_helper.exs +++ b/apps/elixir_ls_utils/test/test_helper.exs @@ -1,5 +1 @@ -if Version.match?(System.version(), ">= 1.11.0") do - Code.put_compiler_option(:warnings_as_errors, true) -end - ExUnit.start(exclude: [pending: true]) diff --git a/apps/language_server/.formatter.exs b/apps/language_server/.formatter.exs index 4629fcb03..cfcc89ae3 100644 --- a/apps/language_server/.formatter.exs +++ b/apps/language_server/.formatter.exs @@ -1,4 +1,7 @@ -impossible_to_format = ["test/fixtures/token_missing_error/lib/has_error.ex"] +impossible_to_format = [ + "test/fixtures/token_missing_error/lib/has_error.ex", + "test/fixtures/project_with_tests/test/error_test.exs" +] [ inputs: diff --git a/apps/language_server/lib/language_server.ex b/apps/language_server/lib/language_server.ex index f913823ef..0e7dc7f18 100644 --- a/apps/language_server/lib/language_server.ex +++ b/apps/language_server/lib/language_server.ex @@ -9,7 +9,9 @@ defmodule ElixirLS.LanguageServer do children = [ {ElixirLS.LanguageServer.Server, ElixirLS.LanguageServer.Server}, {ElixirLS.LanguageServer.JsonRpc, name: ElixirLS.LanguageServer.JsonRpc}, - {ElixirLS.LanguageServer.Providers.WorkspaceSymbols, []} + {ElixirLS.LanguageServer.Providers.WorkspaceSymbols, []}, + {ElixirLS.LanguageServer.Tracer, []}, + {ElixirLS.LanguageServer.ExUnitTestTracer, []} ] opts = [strategy: :one_for_one, name: ElixirLS.LanguageServer.Supervisor, max_restarts: 0] diff --git a/apps/language_server/lib/language_server/build.ex b/apps/language_server/lib/language_server/build.ex index 31c0edc6c..7c84c10d5 100644 --- a/apps/language_server/lib/language_server/build.ex +++ b/apps/language_server/lib/language_server/build.ex @@ -1,17 +1,18 @@ defmodule ElixirLS.LanguageServer.Build do - alias ElixirLS.LanguageServer.{Server, JsonRpc, SourceFile, Diagnostics} + alias ElixirLS.LanguageServer.{Server, JsonRpc, Diagnostics, Tracer} + alias ElixirLS.Utils.MixfileHelpers + require Logger def build(parent, root_path, opts) when is_binary(root_path) do if Path.absname(File.cwd!()) != Path.absname(root_path) do - IO.puts("Skipping build because cwd changed from #{root_path} to #{File.cwd!()}") + Logger.info("Skipping build because cwd changed from #{root_path} to #{File.cwd!()}") {nil, nil} else spawn_monitor(fn -> with_build_lock(fn -> {us, _} = :timer.tc(fn -> - IO.puts("MIX_ENV: #{Mix.env()}") - IO.puts("MIX_TARGET: #{Mix.target()}") + Logger.info("Starting build with MIX_ENV: #{Mix.env()} MIX_TARGET: #{Mix.target()}") case reload_project() do {:ok, mixfile_diagnostics} -> @@ -26,11 +27,7 @@ defmodule ElixirLS.LanguageServer.Build do # if we won't do it elixir >= 1.11 warns that protocols have already been consolidated purge_consolidated_protocols() - {status, diagnostics} = compile() - - if status in [:ok, :noop] and Keyword.get(opts, :load_all_modules?) do - load_all_modules() - end + {status, diagnostics} = run_mix_compile() diagnostics = Diagnostics.normalize(diagnostics, root_path) Server.build_finished(parent, {status, mixfile_diagnostics ++ diagnostics}) @@ -43,99 +40,49 @@ defmodule ElixirLS.LanguageServer.Build do end end) - JsonRpc.log_message(:info, "Compile took #{div(us, 1000)} milliseconds") + Tracer.save() + Logger.info("Compile took #{div(us, 1000)} milliseconds") end) end) end end - def publish_file_diagnostics(uri, all_diagnostics, source_file) do - diagnostics = - all_diagnostics - |> Enum.filter(&(SourceFile.path_to_uri(&1.file) == uri)) - |> Enum.sort_by(fn %{position: position} -> position end) - - diagnostics_json = - for diagnostic <- diagnostics do - severity = - case diagnostic.severity do - :error -> 1 - :warning -> 2 - :information -> 3 - :hint -> 4 - end - - message = - case diagnostic.message do - m when is_binary(m) -> m - m when is_list(m) -> m |> Enum.join("\n") - end - - %{ - "message" => message, - "severity" => severity, - "range" => range(diagnostic.position, source_file), - "source" => diagnostic.compiler_name - } - end - - JsonRpc.notify("textDocument/publishDiagnostics", %{ - "uri" => uri, - "diagnostics" => diagnostics_json - }) - end - - def mixfile_diagnostic({file, line, message}, severity) do - %Mix.Task.Compiler.Diagnostic{ - compiler_name: "ElixirLS", - file: file, - position: line, - message: message, - severity: severity - } - end - - def exception_to_diagnostic(error) do - msg = - case error do - {:shutdown, 1} -> - "Build failed for unknown reason. See output log." - - _ -> - Exception.format_exit(error) - end - - %Mix.Task.Compiler.Diagnostic{ - compiler_name: "ElixirLS", - file: Path.absname(System.get_env("MIX_EXS") || "mix.exs"), - position: nil, - message: msg, - severity: :error, - details: error - } + def clean(clean_deps? \\ false) do + with_build_lock(fn -> + Mix.Task.clear() + run_mix_clean(clean_deps?) + end) end def with_build_lock(func) do :global.trans({__MODULE__, self()}, func) end - defp reload_project do - mixfile = Path.absname(System.get_env("MIX_EXS") || "mix.exs") + def reload_project do + mixfile = Path.absname(MixfileHelpers.mix_exs()) if File.exists?(mixfile) do - # FIXME: Private API - case Mix.ProjectStack.peek() do - %{file: ^mixfile, name: module} -> - # FIXME: Private API - Mix.Project.pop() - purge_module(module) - - _ -> - :ok + if module = Mix.Project.get() do + # FIXME: Private API + Mix.Project.pop() + purge_module(module) end + # We need to clear persistent cache, otherwise `deps.loadpaths` task fails with + # (Mix.Error) Can't continue due to errors on dependencies + # see https://github.com/elixir-lsp/elixir-ls/issues/120 + # originally reported in https://github.com/JakeBecker/elixir-ls/issues/71 + # Note that `Mix.State.clear_cache()` is not enough (at least on elixir 1.14) + # FIXME: Private API + Mix.Dep.clear_cached() + Mix.Task.clear() + # we need to reset compiler options + # project may leave tracers after previous compilation and we don't woant them interfeering + # see https://github.com/elixir-lsp/elixir-ls/issues/717 + set_compiler_options() + # Override build directory to avoid interfering with other dev tools # FIXME: Private API Mix.ProjectStack.post_config(build_path: ".elixir_ls/build") @@ -144,13 +91,13 @@ defmodule ElixirLS.LanguageServer.Build do {status, diagnostics} = case Kernel.ParallelCompiler.compile([mixfile]) do {:ok, _, warnings} -> - {:ok, Enum.map(warnings, &mixfile_diagnostic(&1, :warning))} + {:ok, Enum.map(warnings, &Diagnostics.mixfile_diagnostic(&1, :warning))} {:error, errors, warnings} -> { :error, - Enum.map(warnings, &mixfile_diagnostic(&1, :warning)) ++ - Enum.map(errors, &mixfile_diagnostic(&1, :error)) + Enum.map(warnings, &Diagnostics.mixfile_diagnostic(&1, :warning)) ++ + Enum.map(errors, &Diagnostics.mixfile_diagnostic(&1, :error)) } end @@ -167,36 +114,14 @@ defmodule ElixirLS.LanguageServer.Build do "No mixfile found in project. " <> "To use a subdirectory, set `elixirLS.projectDir` in your settings" - JsonRpc.log_message(:info, msg <> ". Looked for mixfile at #{inspect(mixfile)}") + Logger.warn(msg <> ". Looked for mixfile at #{inspect(mixfile)}") :no_mixfile end end - def load_all_modules do - apps = - cond do - Mix.Project.umbrella?() -> - Mix.Project.apps_paths() |> Map.keys() - - app = Keyword.get(Mix.Project.config(), :app) -> - [app] - - true -> - [] - end - - Enum.each(apps, fn app -> - true = Code.prepend_path(Path.join(Mix.Project.build_path(), "lib/#{app}/ebin")) - - case Application.load(app) do - :ok -> :ok - {:error, {:already_loaded, _}} -> :ok - end - end) - end - - defp compile do + defp run_mix_compile do + # TODO consider adding --no-compile case Mix.Task.run("compile", ["--return-errors", "--ignore-module-conflict"]) do {status, diagnostics} when status in [:ok, :error, :noop] and is_list(diagnostics) -> {status, diagnostics} @@ -209,6 +134,26 @@ defmodule ElixirLS.LanguageServer.Build do end end + defp run_mix_clean(clean_deps?) do + opts = [] + + opts = + if clean_deps? do + opts ++ ["--deps"] + else + opts + end + + results = Mix.Task.run("clean", opts) |> List.wrap() + + if Enum.all?(results, &match?(:ok, &1)) do + :ok + else + Logger.error("mix clean returned #{inspect(results)}") + {:error, :clean_failed} + end + end + defp purge_consolidated_protocols do config = Mix.Project.config() path = Mix.Project.consolidation_path(config) @@ -221,10 +166,7 @@ defmodule ElixirLS.LanguageServer.Build do :ok {:error, reason} -> - JsonRpc.log_message( - :warning, - "Unable to purge consolidated protocols from #{path}: #{inspect(reason)}" - ) + Logger.warn("Unable to purge consolidated protocols from #{path}: #{inspect(reason)}") end # NOTE this implementation is based on https://github.com/phoenixframework/phoenix/commit/b5580e9 @@ -278,39 +220,21 @@ defmodule ElixirLS.LanguageServer.Build do :ok end - defp range(position, nil) when is_integer(position) do - line = position - 1 - - %{ - "start" => %{"line" => line, "character" => 0}, - "end" => %{"line" => line, "character" => 0} - } - end - - defp range(position, source_file) when is_integer(position) do - line = position - 1 - text = Enum.at(SourceFile.lines(source_file), line) || "" - start_idx = String.length(text) - String.length(String.trim_leading(text)) - length = Enum.max([String.length(String.trim(text)), 1]) - - %{ - "start" => %{"line" => line, "character" => start_idx}, - "end" => %{"line" => line, "character" => start_idx + length} - } - end - - defp range({start_line, start_col, end_line, end_col}, _) do - %{ - "start" => %{"line" => start_line - 1, "character" => start_col}, - "end" => %{"line" => end_line - 1, "character" => end_col} - } - end + def set_compiler_options(options \\ [], parser_options \\ []) do + parser_options = + parser_options + |> Keyword.merge( + columns: true, + token_metadata: true + ) - defp range(_, nil) do - %{"start" => %{"line" => 0, "character" => 0}, "end" => %{"line" => 0, "character" => 0}} - end + options = + options + |> Keyword.merge( + tracers: [Tracer], + parser_options: parser_options + ) - defp range(_, source_file) do - SourceFile.full_range(source_file) + Code.compiler_options(options) end end diff --git a/apps/language_server/lib/language_server/cli.ex b/apps/language_server/lib/language_server/cli.ex index d66815a33..22fbcefbe 100644 --- a/apps/language_server/lib/language_server/cli.ex +++ b/apps/language_server/lib/language_server/cli.ex @@ -1,15 +1,48 @@ defmodule ElixirLS.LanguageServer.CLI do alias ElixirLS.Utils.{WireProtocol, Launch} alias ElixirLS.LanguageServer.JsonRpc + alias ElixirLS.LanguageServer.Build + require Logger def main do WireProtocol.intercept_output(&JsonRpc.print/1, &JsonRpc.print_err/1) + + # :logger application is already started + # replace console logger with LSP + Application.put_env(:logger, :backends, [Logger.Backends.JsonRpc]) + + Application.put_env(:logger, Logger.Backends.JsonRpc, + level: :debug, + format: "$message", + metadata: [] + ) + + {:ok, _} = Logger.add_backend(Logger.Backends.JsonRpc) + :ok = Logger.remove_backend(:console, flush: true) + Launch.start_mix() + Application.put_env(:elixir_sense, :logging_enabled, Mix.env() != :prod) + Build.set_compiler_options() + start_language_server() - IO.puts("Started ElixirLS v#{Launch.language_server_version()}") - Launch.print_versions() + Logger.info("Started ElixirLS v#{Launch.language_server_version()}") + + versions = Launch.get_versions() + + Logger.info( + "ElixirLS built with elixir #{versions.compile_elixir_version} on OTP #{versions.compile_otp_version}" + ) + + Logger.info( + "Running on elixir #{versions.current_elixir_version} on OTP #{versions.current_otp_version}" + ) + + check_otp_doc_chunks() + check_elixir_sources() + check_otp_sources() + Launch.limit_num_schedulers() Mix.shell(ElixirLS.LanguageServer.MixShell) @@ -19,25 +52,78 @@ defmodule ElixirLS.LanguageServer.CLI do WireProtocol.stream_packets(&JsonRpc.receive_packet/1) end - defp start_language_server do + defp incomplete_installation_message(hash \\ "") do guide = "https://github.com/elixir-lsp/elixir-ls/blob/master/guides/incomplete-installation.md" + "Unable to start ElixirLS due to an incomplete erlang installation. " <> + "See #{guide}#{hash} for guidance." + end + + defp start_language_server do case Application.ensure_all_started(:language_server, :temporary) do {:ok, _} -> :ok {:error, {:edoc, {'no such file or directory', 'edoc.app'}}} -> - raise "Unable to start ElixirLS due to an incomplete erlang installation. " <> - "See #{guide}#edoc-missing for guidance." + message = incomplete_installation_message("#edoc-missing") + + JsonRpc.show_message(:error, message) + Process.sleep(5000) + raise message {:error, {:dialyzer, {'no such file or directory', 'dialyzer.app'}}} -> - raise "Unable to start ElixirLS due to an incomplete erlang installation. " <> - "See #{guide}#dialyzer-missing for guidance." + message = incomplete_installation_message("#dialyzer-missing") + + JsonRpc.show_message(:error, message) + Process.sleep(5000) + raise message {:error, _} -> - raise "Unable to start ElixirLS due to an incomplete erlang installation. " <> - "See #{guide} for guidance." + message = incomplete_installation_message() + + JsonRpc.show_message(:error, message) + Process.sleep(5000) + raise message + end + end + + def check_otp_doc_chunks() do + if match?({:error, _}, Code.fetch_docs(:erlang)) do + JsonRpc.show_message(:warning, "OTP compiled without EEP48 documentation chunks") + + Logger.warn( + "OTP compiled without EEP48 documentation chunks. Language features for erlang modules will run in limited mode. Please reinstall or rebuild OTP with approperiate flags." + ) + end + end + + def check_elixir_sources() do + enum_ex_path = Enum.module_info()[:compile][:source] + + unless File.exists?(enum_ex_path, [:raw]) do + dir = Path.join(enum_ex_path, "../../../..") |> Path.expand() + + Logger.notice( + "Elixir sources not found (checking in #{dir}). Code navigation to Elixir modules disabled." + ) + end + end + + def check_otp_sources() do + {_module, _binary, beam_filename} = :code.get_object_code(:erlang) + + erlang_erl_path = + beam_filename + |> to_string + |> String.replace(Regex.recompile!(~r/(.+)\/ebin\/([^\s]+)\.beam$/), "\\1/src/\\2.erl") + + unless File.exists?(erlang_erl_path, [:raw]) do + dir = Path.join(erlang_erl_path, "../../../..") |> Path.expand() + + Logger.notice( + "OTP sources not found (checking in #{dir}). Code navigation to OTP modules disabled." + ) end end end diff --git a/apps/language_server/lib/language_server/diagnostics.ex b/apps/language_server/lib/language_server/diagnostics.ex index d826ef802..4da7954c7 100644 --- a/apps/language_server/lib/language_server/diagnostics.ex +++ b/apps/language_server/lib/language_server/diagnostics.ex @@ -1,27 +1,23 @@ defmodule ElixirLS.LanguageServer.Diagnostics do - alias ElixirLS.LanguageServer.SourceFile + alias ElixirLS.LanguageServer.{SourceFile, JsonRpc} + alias ElixirLS.Utils.MixfileHelpers def normalize(diagnostics, root_path) do for diagnostic <- diagnostics do - {type, file, line, description, stacktrace} = + {type, file, position, description, stacktrace} = extract_message_info(diagnostic.message, root_path) diagnostic |> update_message(type, description, stacktrace) |> maybe_update_file(file) - |> maybe_update_position(type, line, stacktrace) + |> maybe_update_position(type, position, stacktrace) end end - defp extract_message_info(list, root_path) when is_list(list) do - list - |> Enum.join() - |> extract_message_info(root_path) - end - defp extract_message_info(diagnostic_message, root_path) do {reversed_stacktrace, reversed_description} = diagnostic_message + |> IO.chardata_to_string() |> String.trim_trailing() |> SourceFile.lines() |> Enum.reverse() @@ -31,9 +27,9 @@ defmodule ElixirLS.LanguageServer.Diagnostics do stacktrace = reversed_stacktrace |> Enum.map(&String.trim/1) |> Enum.reverse() {type, message_without_type} = split_type_and_message(message) - {file, line, description} = split_file_and_description(message_without_type, root_path) + {file, position, description} = split_file_and_description(message_without_type, root_path) - {type, file, line, description, stacktrace} + {type, file, position, description, stacktrace} end defp update_message(diagnostic, type, description, stacktrace) do @@ -67,31 +63,31 @@ defmodule ElixirLS.LanguageServer.Diagnostics do end end - defp maybe_update_position(diagnostic, "TokenMissingError", line, stacktrace) do + defp maybe_update_position(diagnostic, "TokenMissingError", position, stacktrace) do case extract_line_from_missing_hint(diagnostic.message) do - line when is_integer(line) -> + line when is_integer(line) and line > 0 -> %{diagnostic | position: line} _ -> - do_maybe_update_position(diagnostic, line, stacktrace) + do_maybe_update_position(diagnostic, position, stacktrace) end end - defp maybe_update_position(diagnostic, _type, line, stacktrace) do - do_maybe_update_position(diagnostic, line, stacktrace) + defp maybe_update_position(diagnostic, _type, position, stacktrace) do + do_maybe_update_position(diagnostic, position, stacktrace) end - defp do_maybe_update_position(diagnostic, line, stacktrace) do + defp do_maybe_update_position(diagnostic, position, stacktrace) do cond do - line -> - %{diagnostic | position: line} + position != nil -> + %{diagnostic | position: position} diagnostic.position -> diagnostic true -> line = extract_line_from_stacktrace(diagnostic.file, stacktrace) - %{diagnostic | position: line} + %{diagnostic | position: max(line, 0)} end end @@ -106,9 +102,18 @@ defmodule ElixirLS.LanguageServer.Diagnostics do end defp split_file_and_description(message, root_path) do - with {file, line, _column, description} <- get_message_parts(message), + with {file, line, column, description} <- get_message_parts(message), {:ok, path} <- file_path(file, root_path) do - {path, String.to_integer(line), description} + line = String.to_integer(line) + + position = + cond do + line == 0 -> 0 + column == "" -> line + true -> {line, String.to_integer(column)} + end + + {path, position, description} else _ -> {nil, nil, message} @@ -116,9 +121,7 @@ defmodule ElixirLS.LanguageServer.Diagnostics do end defp get_message_parts(message) do - # since elixir 1.11 eex compiler returns line and column on error case Regex.run(~r/^(.*?):(\d+)(:(\d+))?: (.*)/s, message) do - [_, file, line, description] -> {file, line, 0, description} [_, file, line, _, column, description] -> {file, line, column, description} _ -> nil end @@ -178,4 +181,153 @@ defmodule ElixirLS.LanguageServer.Diagnostics do end end) end + + def publish_file_diagnostics(uri, all_diagnostics, source_file) do + diagnostics = + all_diagnostics + |> Enum.filter(&(SourceFile.Path.to_uri(&1.file) == uri)) + |> Enum.sort_by(fn %{position: position} -> position end) + + diagnostics_json = + for diagnostic <- diagnostics do + severity = + case diagnostic.severity do + :error -> 1 + :warning -> 2 + :information -> 3 + :hint -> 4 + end + + message = + case diagnostic.message do + m when is_binary(m) -> m + m when is_list(m) -> m |> Enum.join("\n") + end + + %{ + "message" => message, + "severity" => severity, + "range" => range(diagnostic.position, source_file), + "source" => diagnostic.compiler_name + } + end + + JsonRpc.notify("textDocument/publishDiagnostics", %{ + "uri" => uri, + "diagnostics" => diagnostics_json + }) + end + + def mixfile_diagnostic({file, line, message}, severity) do + %Mix.Task.Compiler.Diagnostic{ + compiler_name: "ElixirLS", + file: file, + position: line, + message: message, + severity: severity + } + end + + def exception_to_diagnostic(error) do + msg = + case error do + {:shutdown, 1} -> + "Build failed for unknown reason. See output log." + + _ -> + Exception.format_exit(error) + end + + %Mix.Task.Compiler.Diagnostic{ + compiler_name: "ElixirLS", + file: Path.absname(MixfileHelpers.mix_exs()), + # 0 means unknown + position: 0, + message: msg, + severity: :error, + details: error + } + end + + # for details see + # https://hexdocs.pm/mix/1.13.4/Mix.Task.Compiler.Diagnostic.html#t:position/0 + # https://microsoft.github.io/language-server-protocol/specifications/specification-3-16/#diagnostic + + # position is a 1 based line number + # we return a range of trimmed text in that line + defp range(position, source_file) + when is_integer(position) and position >= 1 and not is_nil(source_file) do + # line is 1 based + line = position - 1 + text = Enum.at(SourceFile.lines(source_file), line) || "" + + start_idx = String.length(text) - String.length(String.trim_leading(text)) + 1 + length = max(String.length(String.trim(text)), 1) + + %{ + "start" => %{ + "line" => line, + "character" => SourceFile.elixir_character_to_lsp(text, start_idx) + }, + "end" => %{ + "line" => line, + "character" => SourceFile.elixir_character_to_lsp(text, start_idx + length) + } + } + end + + # position is a 1 based line number and 0 based character cursor (UTF8) + # we return a 0 length range exactly at that location + defp range({line_start, char_start}, source_file) + when line_start >= 1 and not is_nil(source_file) do + lines = SourceFile.lines(source_file) + # line is 1 based + start_line = Enum.at(lines, line_start - 1) + # SourceFile.elixir_character_to_lsp assumes char to be 1 based but it's 0 based here + character = SourceFile.elixir_character_to_lsp(start_line, char_start + 1) + + %{ + "start" => %{ + "line" => line_start - 1, + "character" => character + }, + "end" => %{ + "line" => line_start - 1, + "character" => character + } + } + end + + # position is a range defined by 1 based line numbers and 0 based character cursors (UTF8) + # we return exactly that range + defp range({line_start, char_start, line_end, char_end}, source_file) + when line_start >= 1 and line_end >= 1 and not is_nil(source_file) do + lines = SourceFile.lines(source_file) + # line is 1 based + start_line = Enum.at(lines, line_start - 1) + end_line = Enum.at(lines, line_end - 1) + + # SourceFile.elixir_character_to_lsp assumes char to be 1 based but it's 0 based here + start_char = SourceFile.elixir_character_to_lsp(start_line, char_start + 1) + end_char = SourceFile.elixir_character_to_lsp(end_line, char_end + 1) + + %{ + "start" => %{ + "line" => line_start - 1, + "character" => start_char + }, + "end" => %{ + "line" => line_end - 1, + "character" => end_char + } + } + end + + # source file is unknown, position is 0 or invalid + # we discard any position information as it is meaningless + # unfortunately LSP does not allow `null` range so we need to return something + defp range(_, _) do + # we don't care about utf16 positions here as we send 0 + %{"start" => %{"line" => 0, "character" => 0}, "end" => %{"line" => 0, "character" => 0}} + end end diff --git a/apps/language_server/lib/language_server/dialyzer.ex b/apps/language_server/lib/language_server/dialyzer.ex index b0e053583..3b45e97d1 100644 --- a/apps/language_server/lib/language_server/dialyzer.ex +++ b/apps/language_server/lib/language_server/dialyzer.ex @@ -3,6 +3,7 @@ defmodule ElixirLS.LanguageServer.Dialyzer do alias ElixirLS.LanguageServer.Dialyzer.{Manifest, Analyzer, Utils, SuccessTypings} import Utils use GenServer + require Logger defstruct [ :parent, @@ -147,7 +148,7 @@ defmodule ElixirLS.LanguageServer.Dialyzer do state = ElixirLS.LanguageServer.Build.with_build_lock(fn -> if Mix.Project.get() do - JsonRpc.log_message(:info, "[ElixirLS Dialyzer] Checking for stale beam files") + Logger.info("[ElixirLS Dialyzer] Checking for stale beam files") new_timestamp = adjusted_timestamp() {removed_files, file_changes} = @@ -261,8 +262,7 @@ defmodule ElixirLS.LanguageServer.Dialyzer do {changed, changed_contents} end) - JsonRpc.log_message( - :info, + Logger.info( "[ElixirLS Dialyzer] Found #{length(changed)} changed files in #{div(us, 1000)} milliseconds" ) @@ -338,7 +338,7 @@ defmodule ElixirLS.LanguageServer.Dialyzer do :ok {:error, reason} -> - IO.warn("Unable to remove temporary file #{path}: #{inspect(reason)}") + Logger.warn("Unable to remove temporary file #{path}: #{inspect(reason)}") end end @@ -375,8 +375,7 @@ defmodule ElixirLS.LanguageServer.Dialyzer do warnings = Map.drop(warnings, modules_to_analyze) # Analyze! - JsonRpc.log_message( - :info, + Logger.info( "[ElixirLS Dialyzer] Analyzing #{Enum.count(modules_to_analyze)} modules: " <> "#{inspect(modules_to_analyze)}" ) @@ -396,10 +395,7 @@ defmodule ElixirLS.LanguageServer.Dialyzer do {active_plt, mod_deps, md5, warnings} end) - JsonRpc.log_message( - :info, - "[ElixirLS Dialyzer] Analysis finished in #{div(us, 1000)} milliseconds" - ) + Logger.info("[ElixirLS Dialyzer] Analysis finished in #{div(us, 1000)} milliseconds") analysis_finished(parent, :ok, active_plt, mod_deps, md5, warnings, timestamp, build_ref) end @@ -475,18 +471,19 @@ defmodule ElixirLS.LanguageServer.Dialyzer do defp to_diagnostics(warnings_map, warn_opts, warning_format) do tags_enabled = Analyzer.matching_tags(warn_opts) + deps_path = Mix.Project.deps_path() for {_beam_file, warnings} <- warnings_map, - {source_file, line, data} <- warnings, + {source_file, position, data} <- warnings, {tag, _, _} = data, tag in tags_enabled, source_file = Path.absname(to_string(source_file)), in_project?(source_file), - not String.starts_with?(source_file, Mix.Project.deps_path()) do + not String.starts_with?(source_file, deps_path) do %Mix.Task.Compiler.Diagnostic{ compiler_name: "ElixirLS Dialyzer", file: source_file, - position: line, + position: normalize_postion(position), message: warning_message(data, warning_format), severity: :warning, details: data @@ -494,6 +491,22 @@ defmodule ElixirLS.LanguageServer.Dialyzer do end end + # up until OTP 23 position was line :: non_negative_integer + # starting from OTP 24 it is erl_anno:location() :: line | {line, column} + defp normalize_postion({line, column}) when line > 0 do + {line, column} + end + + # 0 means unknown line + defp normalize_postion(line) when line >= 0 do + line + end + + defp normalize_postion(position) do + Logger.warn("dialyzer returned warning with invalid position #{inspect(position)}") + 0 + end + defp warning_message({_, _, {warning_name, args}} = raw_warning, warning_format) when warning_format in ["dialyxir_long", "dialyxir_short"] do format_function = @@ -503,7 +516,7 @@ defmodule ElixirLS.LanguageServer.Dialyzer do end try do - %{^warning_name => warning_module} = Dialyxir.Warnings.warnings() + %{^warning_name => warning_module} = DialyxirVendored.Warnings.warnings() <<_::binary>> = apply(warning_module, format_function, [args]) rescue _ -> warning_message(raw_warning, "dialyzer") @@ -517,8 +530,7 @@ defmodule ElixirLS.LanguageServer.Dialyzer do end defp warning_message(raw_warning, warning_format) do - JsonRpc.log_message( - :info, + Logger.info( "[ElixirLS Dialyzer] Unrecognized dialyzerFormat setting: #{inspect(warning_format)}" <> ", falling back to \"dialyzer\"" ) diff --git a/apps/language_server/lib/language_server/dialyzer/analyzer.ex b/apps/language_server/lib/language_server/dialyzer/analyzer.ex index 4d15b6256..0d8e5dc79 100644 --- a/apps/language_server/lib/language_server/dialyzer/analyzer.ex +++ b/apps/language_server/lib/language_server/dialyzer/analyzer.ex @@ -1,7 +1,16 @@ defmodule ElixirLS.LanguageServer.Dialyzer.Analyzer do require Record + require Logger - # :warn_race_condition is unsupported because it greatly increases analysis time + # warn_race_condition is unsupported because it greatly increases analysis time + # OTP 25 dropped support for warn_race_condition + # see https://github.com/erlang/otp/commit/74c65fbb588b98ee24df9f7302a43552178dfac2 + # TODO remove this comment when OTP >= 25 is required + + # default warns taken from + # https://github.com/erlang/otp/blob/4ed7957623e5ccbd420a09a506bd6bc9930fe93c/lib/dialyzer/src/dialyzer_options.erl#L34 + # macros defined in https://github.com/erlang/otp/blob/4ed7957623e5ccbd420a09a506bd6bc9930fe93c/lib/dialyzer/src/dialyzer.hrl#L36 + # as of OTP 25 @default_warns [ :warn_behaviour, :warn_bin_construction, @@ -20,13 +29,22 @@ defmodule ElixirLS.LanguageServer.Dialyzer.Analyzer do :warn_undefined_callbacks ] @non_default_warns [ - :warn_contract_not_equal, - :warn_contract_subtype, - :warn_contract_supertype, - :warn_return_only_exit, - :warn_umatched_return, - :warn_unknown - ] + :warn_contract_not_equal, + :warn_contract_subtype, + :warn_contract_supertype, + :warn_return_only_exit, + :warn_umatched_return, + :warn_unknown + ] ++ + (if String.to_integer(System.otp_release()) >= 25 do + [ + # OTP >= 25 options + :warn_contract_missing_return, + :warn_contract_extra_return + ] + else + [] + end) @log_cache_length 10 defstruct [ @@ -39,7 +57,44 @@ defmodule ElixirLS.LanguageServer.Dialyzer.Analyzer do log_cache: [] ] - Record.defrecordp(:analysis, Record.extract(:analysis, from_lib: "dialyzer/src/dialyzer.hrl")) + Record.defrecordp( + :analysis_24, + :analysis, + analysis_pid: :undefined, + type: :succ_typings, + defines: [], + doc_plt: :undefined, + files: [], + include_dirs: [], + start_from: :byte_code, + plt: :undefined, + use_contracts: true, + race_detection: false, + behaviours_chk: false, + timing: false, + timing_server: :none, + callgraph_file: [], + solvers: :undefined + ) + + Record.defrecordp( + :analysis_25, + :analysis, + analysis_pid: :undefined, + type: :succ_typings, + defines: [], + doc_plt: :undefined, + files: [], + include_dirs: [], + start_from: :byte_code, + plt: :undefined, + use_contracts: true, + behaviours_chk: false, + timing: false, + timing_server: :none, + callgraph_file: [], + solvers: :undefined + ) def analyze(active_plt, []) do {active_plt, %{}, []} @@ -47,13 +102,21 @@ defmodule ElixirLS.LanguageServer.Dialyzer.Analyzer do def analyze(active_plt, files) do analysis_config = - analysis( - plt: active_plt, - files: files, - type: :succ_typings, - start_from: :byte_code, - solvers: [] - ) + case System.otp_release() |> String.to_integer() do + ver when ver < 25 -> + analysis_24( + plt: active_plt, + files: files, + solvers: [] + ) + + _ -> + analysis_25( + plt: active_plt, + files: files, + solvers: [] + ) + end parent = self() @@ -116,7 +179,9 @@ defmodule ElixirLS.LanguageServer.Dialyzer.Analyzer do end defp print_failure(reason, log_cache) do - IO.puts("Analysis failed: " <> Exception.format_exit(reason)) - for msg <- log_cache, do: IO.puts(msg) + message = + "Analysis failed: " <> Exception.format_exit(reason) <> "\n" <> Enum.join(log_cache, "\n") + + Logger.error(message) end end diff --git a/apps/language_server/lib/language_server/dialyzer/manifest.ex b/apps/language_server/lib/language_server/dialyzer/manifest.ex index d045cc350..bdf09d1a0 100644 --- a/apps/language_server/lib/language_server/dialyzer/manifest.ex +++ b/apps/language_server/lib/language_server/dialyzer/manifest.ex @@ -2,6 +2,7 @@ defmodule ElixirLS.LanguageServer.Dialyzer.Manifest do alias ElixirLS.LanguageServer.{Dialyzer, Dialyzer.Utils, JsonRpc} import Record import Dialyzer.Utils + require Logger @manifest_vsn :v2 @@ -11,10 +12,36 @@ defmodule ElixirLS.LanguageServer.Dialyzer.Manifest do parent = self() Task.start_link(fn -> - active_plt = load_elixir_plt() - transfer_plt(active_plt, parent) + watcher = self() - Dialyzer.analysis_finished(parent, :noop, active_plt, %{}, %{}, %{}, nil, nil) + {pid, ref} = + spawn_monitor(fn -> + active_plt = load_elixir_plt() + send(watcher, :plt_loaded) + transfer_plt(active_plt, parent) + + Dialyzer.analysis_finished(parent, :noop, active_plt, %{}, %{}, %{}, nil, nil) + end) + + receive do + :plt_loaded -> + :ok + + {:DOWN, ^ref, :process, ^pid, reason} -> + JsonRpc.show_message( + :error, + "Unable to build dialyzer PLT. Most likely there are problems with your OTP and elixir installation." + ) + + Logger.error("Dialyzer PLT build process exited with reason: #{inspect(reason)}") + + Logger.warn( + "Dialyzer support disabled. Most likely there are problems with your elixir and OTP installation. Visit https://github.com/elixir-lsp/elixir-ls/issues/540 for help" + ) + + # NOTE We do not call Dialyzer.analysis_finished. LS keeps working and building normally + # only dialyzer is not being triggered after every build + end end) end @@ -50,7 +77,7 @@ defmodule ElixirLS.LanguageServer.Dialyzer.Manifest do # Because the manifest file can be several megabytes, we do a write-then-rename # to reduce the likelihood of corrupting the manifest - JsonRpc.log_message(:info, "[ElixirLS Dialyzer] Writing manifest...") + Logger.info("[ElixirLS Dialyzer] Writing manifest...") File.mkdir_p!(Path.dirname(manifest_path)) tmp_manifest_path = manifest_path <> ".new" File.write!(tmp_manifest_path, :erlang.term_to_binary(manifest_data, compressed: 1)) @@ -58,10 +85,7 @@ defmodule ElixirLS.LanguageServer.Dialyzer.Manifest do File.touch!(manifest_path, timestamp) end) - JsonRpc.log_message( - :info, - "[ElixirLS Dialyzer] Done writing manifest in #{div(us, 1000)} milliseconds." - ) + Logger.info("[ElixirLS Dialyzer] Done writing manifest in #{div(us, 1000)} milliseconds.") end) end diff --git a/apps/language_server/lib/language_server/ex_unit_test_tracer.ex b/apps/language_server/lib/language_server/ex_unit_test_tracer.ex new file mode 100644 index 000000000..9cfc75ec8 --- /dev/null +++ b/apps/language_server/lib/language_server/ex_unit_test_tracer.ex @@ -0,0 +1,115 @@ +defmodule ElixirLS.LanguageServer.ExUnitTestTracer do + use GenServer + + @tables ~w(tests)a + + for table <- @tables do + defp table_name(unquote(table)) do + :"#{__MODULE__}:#{unquote(table)}" + end + end + + def start_link(args) do + GenServer.start_link(__MODULE__, args, name: __MODULE__) + end + + def get_tests(path) do + GenServer.call(__MODULE__, {:get_tests, path}, :infinity) + end + + @impl true + def init(_args) do + for table <- @tables do + table_name = table_name(table) + + :ets.new(table_name, [ + :named_table, + :public, + read_concurrency: true, + write_concurrency: true + ]) + end + + ExUnit.start(autorun: false) + + {:ok, %{}} + end + + @impl true + def handle_call({:get_tests, path}, _from, state) do + :ets.delete_all_objects(table_name(:tests)) + tracers = Code.compiler_options()[:tracers] + # TODO build lock? + Code.put_compiler_option(:tracers, [__MODULE__]) + + result = + try do + # TODO parallel compiler and diagnostics? + _ = Code.compile_file(path) + + result = + :ets.tab2list(table_name(:tests)) + |> Enum.map(fn {{_file, module, line}, describes} -> + %{ + module: inspect(module), + line: line, + describes: describes + } + end) + + {:ok, result} + rescue + e -> + {:error, e} + after + Code.put_compiler_option(:tracers, tracers) + end + + {:reply, result, state} + end + + def trace({:on_module, _, _}, %Macro.Env{} = env) do + test_info = Module.get_attribute(env.module, :ex_unit_tests) + + if test_info != nil do + describe_infos = + test_info + |> Enum.group_by(fn %ExUnit.Test{tags: tags} -> {tags.describe, tags.describe_line} end) + |> Enum.map(fn {{describe, describe_line}, tests} -> + tests = + tests + |> Enum.map(fn %ExUnit.Test{tags: tags} = test -> + # drop test prefix + "test " <> test_name = Atom.to_string(test.name) + + test_name = + if describe != nil do + test_name |> String.replace_prefix(describe <> " ", "") + else + test_name + end + + %{ + name: test_name, + type: tags.test_type, + line: tags.line - 1 + } + end) + + %{ + describe: describe, + line: if(describe_line, do: describe_line - 1), + tests: tests + } + end) + + :ets.insert(table_name(:tests), {{env.file, env.module, env.line - 1}, describe_infos}) + end + + :ok + end + + def trace(_, %Macro.Env{} = _env) do + :ok + end +end diff --git a/apps/language_server/lib/language_server/json_rpc_logger_backend.ex b/apps/language_server/lib/language_server/json_rpc_logger_backend.ex new file mode 100644 index 000000000..a8db91d93 --- /dev/null +++ b/apps/language_server/lib/language_server/json_rpc_logger_backend.ex @@ -0,0 +1,162 @@ +defmodule Logger.Backends.JsonRpc do + @moduledoc ~S""" + A logger backend that logs messages by sending them via LSP ‘window/logMessage’. + + ## Options + + * `:level` - the level to be logged by this backend. + Note that messages are filtered by the general + `:level` configuration for the `:logger` application first. + + * `:format` - the format message used to print logs. + Defaults to: `"$message"`. + It may also be a `{module, function}` tuple that is invoked + with the log level, the message, the current timestamp and + the metadata and must return `t:IO.chardata/0`. See + `Logger.Formatter`. + + * `:metadata` - the metadata to be printed by `$metadata`. + Defaults to an empty list (no metadata). + Setting `:metadata` to `:all` prints all metadata. See + the "Metadata" section for more information. + + """ + + @behaviour :gen_event + + defstruct format: nil, + level: nil, + metadata: nil + + @impl true + def init(__MODULE__) do + config = Application.get_env(:logger, __MODULE__) + + {:ok, init(config, %__MODULE__{})} + end + + def init({__MODULE__, opts}) when is_list(opts) do + config = configure_merge(Application.get_env(:logger, __MODULE__), opts) + {:ok, init(config, %__MODULE__{})} + end + + @impl true + def handle_call({:configure, options}, state) do + {:ok, :ok, configure(options, state)} + end + + def handle_call({:set_group_leader, pid}, state) do + Process.group_leader(self(), pid) + {:ok, :ok, state} + end + + @impl true + def handle_event({level, _gl, {Logger, msg, ts, md}}, state) do + %{level: log_level} = state + + {:erl_level, level} = List.keyfind(md, :erl_level, 0, {:erl_level, level}) + + cond do + not meet_level?(level, log_level) -> + {:ok, state} + + true -> + {:ok, log_event(level, msg, ts, md, state)} + end + end + + def handle_event(:flush, state) do + {:ok, state} + end + + def handle_event(_, state) do + {:ok, state} + end + + @impl true + def handle_info(_, state) do + {:ok, state} + end + + @impl true + def code_change(_old_vsn, state, _extra) do + {:ok, state} + end + + @impl true + def terminate(_reason, _state) do + :ok + end + + ## Helpers + + defp meet_level?(_lvl, nil), do: true + + defp meet_level?(lvl, min) do + Logger.compare_levels(lvl, min) != :lt + end + + defp configure(options, state) do + config = configure_merge(Application.get_env(:logger, __MODULE__), options) + Application.put_env(:logger, __MODULE__, config) + init(config, state) + end + + defp init(config, state) do + level = Keyword.get(config, :level) + format = Logger.Formatter.compile(Keyword.get(config, :format)) + metadata = Keyword.get(config, :metadata, []) |> configure_metadata() + + %{ + state + | format: format, + metadata: metadata, + level: level + } + end + + defp configure_metadata(:all), do: :all + defp configure_metadata(metadata), do: Enum.reverse(metadata) + + defp configure_merge(env, options) do + Keyword.merge(env, options, fn + _, _v1, v2 -> v2 + end) + end + + defp log_event(level, msg, ts, md, state) do + output = format_event(level, msg, ts, md, state) |> IO.chardata_to_string() + ElixirLS.LanguageServer.JsonRpc.log_message(elixir_log_level_to_lsp(level), output) + state + end + + defp elixir_log_level_to_lsp(:debug), do: :log + defp elixir_log_level_to_lsp(:info), do: :info + defp elixir_log_level_to_lsp(:notice), do: :info + defp elixir_log_level_to_lsp(:warning), do: :warning + defp elixir_log_level_to_lsp(:warn), do: :warning + defp elixir_log_level_to_lsp(:error), do: :error + defp elixir_log_level_to_lsp(:critical), do: :error + defp elixir_log_level_to_lsp(:alert), do: :error + defp elixir_log_level_to_lsp(:emergency), do: :error + + defp format_event(level, msg, ts, md, state) do + %{format: format, metadata: keys} = state + + format + |> Logger.Formatter.format(level, msg, ts, take_metadata(md, keys)) + end + + defp take_metadata(metadata, :all) do + metadata + end + + defp take_metadata(metadata, keys) do + Enum.reduce(keys, [], fn key, acc -> + case Keyword.fetch(metadata, key) do + {:ok, val} -> [{key, val} | acc] + :error -> acc + end + end) + end +end diff --git a/apps/language_server/lib/language_server/protocol.ex b/apps/language_server/lib/language_server/protocol.ex index 044f496d0..e30821505 100644 --- a/apps/language_server/lib/language_server/protocol.ex +++ b/apps/language_server/lib/language_server/protocol.ex @@ -219,6 +219,17 @@ defmodule ElixirLS.LanguageServer.Protocol do end end + defmacro code_action_req(id, uri, diagnostics) do + quote do + request(unquote(id), "textDocument/codeAction", %{ + "context" => %{"diagnostics" => unquote(diagnostics)}, + "textDocument" => %{ + "uri" => unquote(uri) + } + }) + end + end + # Other utilities defmacro range(start_line, start_character, end_line, end_character) do diff --git a/apps/language_server/lib/language_server/protocol/location.ex b/apps/language_server/lib/language_server/protocol/location.ex index 8f3bf9baa..a8eaa2857 100644 --- a/apps/language_server/lib/language_server/protocol/location.ex +++ b/apps/language_server/lib/language_server/protocol/location.ex @@ -8,26 +8,30 @@ defmodule ElixirLS.LanguageServer.Protocol.Location do defstruct [:uri, :range] alias ElixirLS.LanguageServer.SourceFile - alias ElixirLS.LanguageServer.Protocol + require ElixirLS.LanguageServer.Protocol, as: Protocol - def new(%ElixirSense.Location{file: file, line: line, column: column}, uri) do + def new( + %ElixirSense.Location{file: file, line: line, column: column}, + current_file_uri, + current_file_text + ) do uri = case file do - nil -> uri - _ -> SourceFile.path_to_uri(file) + nil -> current_file_uri + _ -> SourceFile.Path.to_uri(file) end - # LSP messages are 0 indexed whilst elixir/erlang is 1 indexed. - # Guard against malformed line or column values. - line = max(line - 1, 0) - column = max(column - 1, 0) + text = + case file do + nil -> current_file_text + file -> File.read!(file) + end + + {line, column} = SourceFile.elixir_position_to_lsp(text, {line, column}) %Protocol.Location{ uri: uri, - range: %{ - "start" => %{"line" => line, "character" => column}, - "end" => %{"line" => line, "character" => column} - } + range: Protocol.range(line, column, line, column) } end end diff --git a/apps/language_server/lib/language_server/providers/code_action.ex b/apps/language_server/lib/language_server/providers/code_action.ex new file mode 100644 index 000000000..288b9bfd3 --- /dev/null +++ b/apps/language_server/lib/language_server/providers/code_action.ex @@ -0,0 +1,61 @@ +defmodule ElixirLS.LanguageServer.Providers.CodeAction do + use ElixirLS.LanguageServer.Protocol + + def code_actions(uri, diagnostics) do + actions = + diagnostics + |> Enum.map(fn diagnostic -> actions(uri, diagnostic) end) + |> List.flatten() + + {:ok, actions} + end + + defp actions(uri, %{"message" => message} = diagnostic) do + [ + {~r/variable "(.*)" is unused/, &prefix_with_underscore/2}, + {~r/variable "(.*)" is unused/, &remove_variable/2} + ] + |> Enum.filter(fn {r, _fun} -> String.match?(message, r) end) + |> Enum.map(fn {_r, fun} -> fun.(uri, diagnostic) end) + end + + defp prefix_with_underscore(uri, %{"range" => range}) do + %{ + "title" => "Add '_' to unused variable", + "kind" => "quickfix", + "edit" => %{ + "changes" => %{ + uri => [ + %{ + "newText" => "_", + "range" => + range( + range["start"]["line"], + range["start"]["character"], + range["start"]["line"], + range["start"]["character"] + ) + } + ] + } + } + } + end + + defp remove_variable(uri, %{"range" => range}) do + %{ + "title" => "Remove unused variable", + "kind" => "quickfix", + "edit" => %{ + "changes" => %{ + uri => [ + %{ + "newText" => "", + "range" => range + } + ] + } + } + } + end +end diff --git a/apps/language_server/lib/language_server/providers/code_lens.ex b/apps/language_server/lib/language_server/providers/code_lens.ex index 0d035d772..6367677c7 100644 --- a/apps/language_server/lib/language_server/providers/code_lens.ex +++ b/apps/language_server/lib/language_server/providers/code_lens.ex @@ -17,6 +17,7 @@ defmodule ElixirLS.LanguageServer.Providers.CodeLens do def build_code_lens(line, title, command, argument) do %{ + # we don't care about utf16 positions here as we send 0 "range" => range(line - 1, 0, line - 1, 0), "command" => %{ "title" => title, diff --git a/apps/language_server/lib/language_server/providers/code_lens/test.ex b/apps/language_server/lib/language_server/providers/code_lens/test.ex index 8b18e9499..bf3c445f8 100644 --- a/apps/language_server/lib/language_server/providers/code_lens/test.ex +++ b/apps/language_server/lib/language_server/providers/code_lens/test.ex @@ -20,7 +20,7 @@ defmodule ElixirLS.LanguageServer.Providers.CodeLens.Test do with {:ok, buffer_file_metadata} <- parse_source(text) do source_lines = SourceFile.lines(text) - file_path = SourceFile.path_from_uri(uri) + file_path = SourceFile.Path.from_uri(uri) calls_list = buffer_file_metadata.calls diff --git a/apps/language_server/lib/language_server/providers/completion.ex b/apps/language_server/lib/language_server/providers/completion.ex index 7ea6f767a..c4cd2fefd 100644 --- a/apps/language_server/lib/language_server/providers/completion.ex +++ b/apps/language_server/lib/language_server/providers/completion.ex @@ -7,7 +7,9 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do with the Language Server Protocol. We also attempt to determine the context based on the line text before the cursor so we can filter out suggestions that are not relevant. """ + alias ElixirLS.LanguageServer.Protocol.TextEdit alias ElixirLS.LanguageServer.SourceFile + import ElixirLS.LanguageServer.Protocol, only: [range: 4] @enforce_keys [:label, :kind, :insert_text, :priority, :tags] defstruct [ @@ -20,7 +22,9 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do # Lower priority is shown higher in the result list :priority, :tags, - :command + :command, + {:preselect, false}, + :additional_text_edit ] @func_snippets %{ @@ -90,16 +94,22 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do |> SourceFile.lines() |> Enum.at(line) - text_before_cursor = String.slice(line_text, 0, character) - text_after_cursor = String.slice(line_text, character..-1) + # convert to 1 based utf8 position + line = line + 1 + character = SourceFile.lsp_character_to_elixir(line_text, character) + + text_before_cursor = String.slice(line_text, 0, character - 1) + text_after_cursor = String.slice(line_text, (character - 1)..-1) prefix = get_prefix(text_before_cursor) # TODO: Don't call into here directly # Can we use ElixirSense.Providers.Suggestion? ElixirSense.suggestions/3 + metadata = ElixirSense.Core.Parser.parse_string(text, true, true, line) + env = - ElixirSense.Core.Parser.parse_string(text, true, true, line + 1) - |> ElixirSense.Core.Metadata.get_env(line + 1) + metadata + |> ElixirSense.Core.Metadata.get_env(line) scope = case env.scope do @@ -134,8 +144,18 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do module: env.module } + position_to_insert_alias = + ElixirSense.Core.Metadata.get_position_to_insert_alias(metadata, line) || {line, 0} + + context = + Map.put( + context, + :position_to_insert_alias, + SourceFile.elixir_position_to_lsp(text, position_to_insert_alias) + ) + items = - ElixirSense.suggestions(text, line + 1, character + 1) + ElixirSense.suggestions(text, line, character, required_alias: true) |> maybe_reject_derived_functions(context, options) |> Enum.map(&from_completion_item(&1, context, options)) |> maybe_add_do(context) @@ -159,7 +179,9 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do detail: "keyword", insert_text: "do\n $0\nend", tags: [], - priority: 0 + priority: 0, + # force selection over other longer not exact completions + preselect: true } [item | completion_items] @@ -278,6 +300,48 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do } end + defp from_completion_item( + %{ + type: :module, + name: name, + summary: summary, + subtype: subtype, + metadata: metadata, + required_alias: required_alias + }, + %{ + def_before: nil, + position_to_insert_alias: {line_to_insert_alias, column_to_insert_alias} + }, + options + ) do + completion_without_additional_text_edit = + from_completion_item( + %{type: :module, name: name, summary: summary, subtype: subtype, metadata: metadata}, + %{def_before: nil}, + options + ) + + alias_value = + Atom.to_string(required_alias) + |> String.replace_prefix("Elixir.", "") + + indentation = + if column_to_insert_alias >= 1, + do: 1..column_to_insert_alias |> Enum.map_join(fn _ -> " " end), + else: "" + + alias_edit = indentation <> "alias " <> alias_value <> "\n" + + struct(completion_without_additional_text_edit, + additional_text_edit: %TextEdit{ + range: range(line_to_insert_alias, 0, line_to_insert_alias, 0), + newText: alias_edit + }, + documentation: alias_value <> "\n" <> summary + ) + end + defp from_completion_item( %{type: :module, name: name, summary: summary, subtype: subtype, metadata: metadata}, %{def_before: nil}, @@ -561,7 +625,9 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do completion end - if snippet = snippet_for({origin, name}, context) do + file_path = Keyword.get(options, :file_path) + + if snippet = snippet_for({origin, name}, Map.put(context, :file_path, file_path)) do %{completion | insert_text: snippet, kind: :snippet, label: name} else completion @@ -572,6 +638,17 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do nil end + defp snippet_for({"Kernel", "defmodule"}, %{file_path: file_path}) when is_binary(file_path) do + # In a mix project the file_path can be something like "/some/code/path/project/lib/project/sub_path/my_file.ex" + # so we'll try to guess the appropriate module name from the path + "defmodule #{suggest_module_name(file_path)}$1 do\n\t$0\nend" + end + + defp snippet_for({"Kernel", "defprotocol"}, %{file_path: file_path}) + when is_binary(file_path) do + "defprotocol #{suggest_module_name(file_path)}$1 do\n\t$0\nend" + end + defp snippet_for(key, %{pipe_before?: true}) do # Get pipe-friendly version of snippet if available, otherwise fallback to standard Map.get(@pipe_func_snippets, key) || Map.get(@func_snippets, key) @@ -589,6 +666,77 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do end end + def suggest_module_name(file_path) when is_binary(file_path) do + file_path + |> Path.split() + |> Enum.reverse() + |> do_suggest_module_name() + end + + defp do_suggest_module_name([]), do: nil + + defp do_suggest_module_name([filename | reversed_path]) do + filename + |> String.split(".") + |> case do + [file, "ex"] -> + do_suggest_module_name(reversed_path, [file], topmost_parent: "lib") + + [file, "exs"] -> + if String.ends_with?(file, "_test") do + do_suggest_module_name(reversed_path, [file], topmost_parent: "test") + else + nil + end + + _otherwise -> + nil + end + end + + defp do_suggest_module_name([dir | _rest], module_name_acc, topmost_parent: topmost) + when dir == topmost do + module_name_acc + |> Enum.map(&Macro.camelize/1) + |> Enum.join(".") + end + + defp do_suggest_module_name( + [probable_phoenix_dir | [project_web_dir | _] = rest], + module_name_acc, + opts + ) + when probable_phoenix_dir in [ + "controllers", + "views", + "channels", + "plugs", + "endpoints", + "sockets", + "live", + "components" + ] do + if String.ends_with?(project_web_dir, "_web") do + # by convention Phoenix doesn't use these folders as part of the module names + # for modules located inside them, so we'll try to do the same + do_suggest_module_name(rest, module_name_acc, opts) + else + # when not directly under the *_web folder however then we should make the folder + # part of the module's name + do_suggest_module_name(rest, [probable_phoenix_dir | module_name_acc], opts) + end + end + + defp do_suggest_module_name([dir_name | rest], module_name_acc, opts) do + do_suggest_module_name(rest, [dir_name | module_name_acc], opts) + end + + defp do_suggest_module_name([], _module_name_acc, _opts) do + # we went all the way up without ever encountering a 'lib' or a 'test' folder + # so we ignore the accumulated module name because it's probably wrong/useless + nil + end + def function_snippet(name, args, arity, opts) do snippets_supported? = Keyword.get(opts, :snippets_supported, false) trigger_signature? = Keyword.get(opts, :trigger_signature?, false) @@ -864,7 +1012,15 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do end defp sort_items(items) do - Enum.sort_by(items, fn %__MODULE__{priority: priority, label: label} -> + Enum.sort_by(items, fn %__MODULE__{priority: priority, label: label} = item -> + # deprioretize deprecated + priority = + if item.tags |> Enum.any?(&(&1 == :deprecated)) do + priority + 30 + else + priority + end + {priority, label =~ Regex.recompile!(~r/^[^a-zA-Z0-9]/), label} end) end @@ -891,6 +1047,12 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do "filterText" => item.filter_text, "sortText" => String.pad_leading(to_string(idx), 8, "0"), "insertText" => item.insert_text, + "additionalTextEdits" => + if item.additional_text_edit do + [item.additional_text_edit] + else + nil + end, "command" => item.command, "insertTextFormat" => if Keyword.get(options, :snippets_supported, false) do @@ -900,6 +1062,13 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do end } + json = + if item.preselect do + Map.put(json, "preselect", true) + else + json + end + # deprecated as of Language Server Protocol Specification - 3.15 json = if Keyword.get(options, :deprecated_supported, false) do diff --git a/apps/language_server/lib/language_server/providers/definition.ex b/apps/language_server/lib/language_server/providers/definition.ex index 3b64c0f92..1898a5c73 100644 --- a/apps/language_server/lib/language_server/providers/definition.ex +++ b/apps/language_server/lib/language_server/providers/definition.ex @@ -3,16 +3,18 @@ defmodule ElixirLS.LanguageServer.Providers.Definition do Go-to-definition provider utilizing Elixir Sense """ - alias ElixirLS.LanguageServer.Protocol + alias ElixirLS.LanguageServer.{Protocol, SourceFile} def definition(uri, text, line, character) do + {line, character} = SourceFile.lsp_position_to_elixir(text, {line, character}) + result = - case ElixirSense.definition(text, line + 1, character + 1) do + case ElixirSense.definition(text, line, character) do nil -> nil %ElixirSense.Location{} = location -> - Protocol.Location.new(location, uri) + Protocol.Location.new(location, uri, text) end {:ok, result} diff --git a/apps/language_server/lib/language_server/providers/document_symbols.ex b/apps/language_server/lib/language_server/providers/document_symbols.ex index 0df6357ac..9ee3263de 100644 --- a/apps/language_server/lib/language_server/providers/document_symbols.ex +++ b/apps/language_server/lib/language_server/providers/document_symbols.ex @@ -6,18 +6,22 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbols do """ alias ElixirLS.LanguageServer.Providers.SymbolUtils - alias ElixirLS.LanguageServer.Protocol + alias ElixirLS.LanguageServer.SourceFile + require ElixirLS.LanguageServer.Protocol, as: Protocol defmodule Info do - defstruct [:type, :name, :location, :children] + defstruct [:type, :name, :location, :children, :selection_location, :symbol] end @defs [:def, :defp, :defmacro, :defmacrop, :defguard, :defguardp, :defdelegate] - @docs [ + @supplementing_attributes [ :doc, :moduledoc, - :typedoc + :typedoc, + :spec, + :impl, + :deprecated ] @max_parser_errors 6 @@ -25,27 +29,27 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbols do def symbols(uri, text, hierarchical) do case list_symbols(text) do {:ok, symbols} -> - {:ok, build_symbols(symbols, uri, hierarchical)} + {:ok, build_symbols(symbols, uri, text, hierarchical)} {:error, :compilation_error} -> {:error, :server_error, "[DocumentSymbols] Compilation error while parsing source file"} end end - defp build_symbols(symbols, uri, hierarchical) + defp build_symbols(symbols, uri, text, hierarchical) - defp build_symbols(symbols, uri, true) do - Enum.map(symbols, &build_symbol_information_hierarchical(uri, &1)) + defp build_symbols(symbols, uri, text, true) do + Enum.map(symbols, &build_symbol_information_hierarchical(uri, text, &1)) end - defp build_symbols(symbols, uri, false) do + defp build_symbols(symbols, uri, text, false) do symbols - |> Enum.map(&build_symbol_information_flat(uri, &1)) + |> Enum.map(&build_symbol_information_flat(uri, text, &1)) |> List.flatten() end defp list_symbols(src) do - case ElixirSense.string_to_quoted(src, 1, @max_parser_errors, line: 1) do + case ElixirSense.string_to_quoted(src, 1, @max_parser_errors, line: 1, token_metadata: true) do {:ok, quoted_form} -> {:ok, extract_modules(quoted_form)} {:error, _error} -> {:error, :compilation_error} end @@ -56,6 +60,12 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbols do ast |> Enum.map(&extract_modules(&1)) |> List.flatten() end + # handle a bare defimpl, defprotocol or defmodule + defp extract_modules({defname, _, nil}) + when defname in [:defmodule, :defprotocol, :defimpl] do + [] + end + defp extract_modules({defname, _, _child_ast} = ast) when defname in [:defmodule, :defprotocol, :defimpl] do [extract_symbol("", ast)] @@ -68,20 +78,29 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbols do defp extract_modules(_ast), do: [] # Modules, protocols + defp extract_symbol(_module_name, {defname, location, arguments}) when defname in [:defmodule, :defprotocol] do - {module_name, module_body} = + {module_name, module_name_location, module_body} = case arguments do # Handles `defmodule do\nend` type compile errors [[do: module_body]] -> # The LSP requires us to return a non-empty name case defname do - :defmodule -> {"MISSING_MODULE_NAME", module_body} - :defprotocol -> {"MISSING_PROTOCOL_NAME", module_body} + :defmodule -> {"MISSING_MODULE_NAME", nil, module_body} + :defprotocol -> {"MISSING_PROTOCOL_NAME", nil, module_body} end [module_expression, [do: module_body]] -> - {extract_module_name(module_expression), module_body} + module_name_location = + case module_expression do + {_, location, _} -> location + _ -> nil + end + + # TODO extract module name location from Code.Fragment.surround_context? + # TODO better selection ranges for defimpl? + {extract_module_name(module_expression), module_name_location, module_body} end mod_defns = @@ -101,7 +120,14 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbols do :defprotocol -> :interface end - %Info{type: type, name: module_name, location: location, children: module_symbols} + %Info{ + type: type, + name: module_name, + location: location, + selection_location: module_name_location, + children: module_symbols, + symbol: module_name + } end # Protocol implementations @@ -117,14 +143,8 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbols do end # Struct and exception - defp extract_symbol(_module_name, {defname, location, [properties | _]}) + defp extract_symbol(module_name, {defname, location, [properties | _]}) when defname in [:defstruct, :defexception] do - name = - case defname do - :defstruct -> "struct" - :defexception -> "exception" - end - children = if is_list(properties) do properties @@ -134,88 +154,116 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbols do [] end - %Info{type: :struct, name: name, location: location, children: children} + # TODO there is no end/closing metadata in the AST + + %Info{ + type: :struct, + name: "#{defname} #{module_name}", + location: location, + children: children + } end - # Docs - defp extract_symbol(_, {:@, _, [{kind, _, _}]}) when kind in @docs, do: nil + # We skip attributes only supplementoing the symbol + defp extract_symbol(_, {:@, _, [{kind, _, _}]}) when kind in @supplementing_attributes, do: nil # Types - defp extract_symbol(_current_module, {:@, _, [{type_kind, location, type_expression}]}) - when type_kind in [:type, :typep, :opaque, :spec, :callback, :macrocallback] do - type_name = + defp extract_symbol(_current_module, {:@, location, [{type_kind, _, type_expression}]}) + when type_kind in [:type, :typep, :opaque, :callback, :macrocallback] do + {type_name, type_head_location} = case type_expression do - [{:"::", _, [{_, _, _} = type_head | _]}] -> - Macro.to_string(type_head) + [{:"::", _, [{_, type_head_location, _} = type_head | _]}] -> + {Macro.to_string(type_head), type_head_location} - [{:when, _, [{:"::", _, [{_, _, _} = type_head, _]}, _]}] -> - Macro.to_string(type_head) + [{:when, _, [{:"::", _, [{_, type_head_location, _} = type_head, _]}, _]}] -> + {Macro.to_string(type_head), type_head_location} end + + type_name = + type_name |> String.replace("\n", "") type = if type_kind in [:type, :typep, :opaque], do: :class, else: :event %Info{ type: type, - name: type_name, + name: "@#{type_kind} #{type_name}", location: location, + selection_location: type_head_location, + symbol: "#{type_name}", children: [] } end # @behaviour BehaviourModule - defp extract_symbol(_current_module, {:@, _, [{:behaviour, location, [behaviour_expression]}]}) do + defp extract_symbol(_current_module, {:@, location, [{:behaviour, _, [behaviour_expression]}]}) do module_name = extract_module_name(behaviour_expression) - %Info{type: :constant, name: "@behaviour #{module_name}", location: location, children: []} - end - - # @impl true - defp extract_symbol(_current_module, {:@, _, [{:impl, location, [true]}]}) do - %Info{type: :constant, name: "@impl true", location: location, children: []} - end - - # @impl BehaviourModule - defp extract_symbol(_current_module, {:@, _, [{:impl, location, [impl_expression]}]}) do - module_name = extract_module_name(impl_expression) - - %Info{type: :constant, name: "@impl #{module_name}", location: location, children: []} + %Info{type: :interface, name: "@behaviour #{module_name}", location: location, children: []} end # Other attributes - defp extract_symbol(_current_module, {:@, _, [{name, location, _}]}) do + defp extract_symbol(_current_module, {:@, location, [{name, _, _}]}) do %Info{type: :constant, name: "@#{name}", location: location, children: []} end # Function, macro, guard with when defp extract_symbol( _current_module, - {defname, _, [{:when, _, [{_, location, _} = fn_head, _]} | _]} + {defname, location, [{:when, _, [{_, head_location, _} = fn_head, _]} | _]} ) when defname in @defs do name = Macro.to_string(fn_head) |> String.replace("\n", "") %Info{ type: :function, + symbol: "#{name}", name: "#{defname} #{name}", location: location, + selection_location: head_location, children: [] } end # Function, macro, delegate - defp extract_symbol(_current_module, {defname, _, [{_, location, _} = fn_head | _]}) + defp extract_symbol(_current_module, {defname, location, [{_, head_location, _} = fn_head | _]}) when defname in @defs do name = Macro.to_string(fn_head) |> String.replace("\n", "") %Info{ type: :function, + symbol: "#{name}", name: "#{defname} #{name}", location: location, + selection_location: head_location, children: [] } end + defp extract_symbol( + _current_module, + {{:., _, [{:__aliases__, alias_location, [:Record]}, :defrecord]}, location, + [record_name, properties]} + ) do + name = Macro.to_string(record_name) |> String.replace("\n", "") + + children = + if is_list(properties) do + properties + |> Enum.map(&extract_property(&1, location)) + |> Enum.reject(&is_nil/1) + else + [] + end + + %Info{ + type: :class, + name: "defrecord #{name}", + location: location |> Keyword.merge(Keyword.take(alias_location, [:line, :column])), + children: children + } + end + # ExUnit test defp extract_symbol(_current_module, {:test, location, [name | _]}) do %Info{ @@ -264,44 +312,94 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbols do keys = case config_entry do list when is_list(list) -> - list - |> Enum.map(fn {key, _} -> Macro.to_string(key) end) + string_list = + list + |> Enum.map_join(", ", fn {key, _} -> Macro.to_string(key) end) + + "[#{string_list}]" key -> - [Macro.to_string(key)] + Macro.to_string(key) end - for key <- keys do - %Info{ - type: :key, - name: "config :#{app} #{key}", - location: location, - children: [] - } - end + %Info{ + type: :key, + name: "config :#{app} #{keys}", + location: location, + children: [] + } end defp extract_symbol(_, _), do: nil - defp build_symbol_information_hierarchical(uri, info) when is_list(info), - do: Enum.map(info, &build_symbol_information_hierarchical(uri, &1)) + defp build_symbol_information_hierarchical(uri, text, info) when is_list(info), + do: Enum.map(info, &build_symbol_information_hierarchical(uri, text, &1)) + + defp build_symbol_information_hierarchical(uri, text, %Info{} = info) do + selection_range = + location_to_range(info.selection_location || info.location, text, info.symbol) + + # range must contain selection range + range = + location_to_range(info.location, text, nil) + |> maybe_extend_range(selection_range) - defp build_symbol_information_hierarchical(uri, %Info{} = info) do %Protocol.DocumentSymbol{ name: info.name, kind: SymbolUtils.symbol_kind_to_code(info.type), - range: location_to_range(info.location), - selectionRange: location_to_range(info.location), - children: build_symbol_information_hierarchical(uri, info.children) + range: range, + selectionRange: selection_range, + children: build_symbol_information_hierarchical(uri, text, info.children) } end - defp build_symbol_information_flat(uri, info, parent_name \\ nil) + defp maybe_extend_range( + Protocol.range(start_line, start_character, end_line, end_character), + Protocol.range( + selection_start_line, + selection_start_character, + selection_end_line, + selection_end_character + ) + ) do + {extended_start_line, extended_start_character} = + cond do + selection_start_line < start_line -> + {selection_start_line, selection_start_character} + + selection_start_line == start_line -> + {selection_start_line, min(selection_start_character, start_character)} + + true -> + {start_line, start_character} + end + + {extended_end_line, extended_end_character} = + cond do + selection_end_line > end_line -> + {selection_end_line, selection_end_character} + + selection_end_line == end_line -> + {selection_end_line, max(selection_end_character, end_character)} + + true -> + {end_line, end_character} + end + + Protocol.range( + extended_start_line, + extended_start_character, + extended_end_line, + extended_end_character + ) + end + + defp build_symbol_information_flat(uri, text, info, parent_name \\ nil) - defp build_symbol_information_flat(uri, info, parent_name) when is_list(info), - do: Enum.map(info, &build_symbol_information_flat(uri, &1, parent_name)) + defp build_symbol_information_flat(uri, text, info, parent_name) when is_list(info), + do: Enum.map(info, &build_symbol_information_flat(uri, text, &1, parent_name)) - defp build_symbol_information_flat(uri, %Info{} = info, parent_name) do + defp build_symbol_information_flat(uri, text, %Info{} = info, parent_name) do case info.children do [_ | _] -> [ @@ -310,11 +408,11 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbols do kind: SymbolUtils.symbol_kind_to_code(info.type), location: %{ uri: uri, - range: location_to_range(info.location) + range: location_to_range(info.location, text, nil) }, containerName: parent_name } - | Enum.map(info.children, &build_symbol_information_flat(uri, &1, info.name)) + | Enum.map(info.children, &build_symbol_information_flat(uri, text, &1, info.name)) ] _ -> @@ -323,18 +421,44 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbols do kind: SymbolUtils.symbol_kind_to_code(info.type), location: %{ uri: uri, - range: location_to_range(info.location) + range: location_to_range(info.location, text, nil) }, containerName: parent_name } end end - defp location_to_range(location) do - %{ - start: %{line: location[:line] - 1, character: location[:column] - 1}, - end: %{line: location[:line] - 1, character: location[:column] - 1} - } + defp location_to_range(location, text, symbol) do + {start_line, start_character} = + SourceFile.elixir_position_to_lsp(text, {location[:line], location[:column]}) + + {end_line, end_character} = + cond do + end_location = location[:end_of_expression] -> + SourceFile.elixir_position_to_lsp(text, {end_location[:line], end_location[:column]}) + + end_location = location[:end] -> + SourceFile.elixir_position_to_lsp( + text, + {end_location[:line], end_location[:column] + 3} + ) + + end_location = location[:closing] -> + # all closing tags we expect hera are 1 char width + SourceFile.elixir_position_to_lsp( + text, + {end_location[:line], end_location[:column] + 1} + ) + + symbol != nil -> + end_char = SourceFile.elixir_character_to_lsp(symbol, String.length(symbol)) + {start_line, start_character + end_char + 1} + + true -> + {start_line, start_character} + end + + Protocol.range(start_line, start_character, end_line, end_character) end defp extract_module_name(protocol: protocol, implementations: implementations) do diff --git a/apps/language_server/lib/language_server/providers/execute_command.ex b/apps/language_server/lib/language_server/providers/execute_command.ex index 63b9fdc27..414759acc 100644 --- a/apps/language_server/lib/language_server/providers/execute_command.ex +++ b/apps/language_server/lib/language_server/providers/execute_command.ex @@ -9,7 +9,9 @@ defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand do "spec" => ExecuteCommand.ApplySpec, "expandMacro" => ExecuteCommand.ExpandMacro, "manipulatePipes" => ExecuteCommand.ManipulatePipes, - "restart" => ExecuteCommand.Restart + "restart" => ExecuteCommand.Restart, + "mixClean" => ExecuteCommand.MixClean, + "getExUnitTestsInFile" => ExecuteCommand.GetExUnitTestsInFile } @callback execute([any], %ElixirLS.LanguageServer.Server{}) :: diff --git a/apps/language_server/lib/language_server/providers/execute_command/apply_spec.ex b/apps/language_server/lib/language_server/providers/execute_command/apply_spec.ex index 96fbdae2b..11be42cfe 100644 --- a/apps/language_server/lib/language_server/providers/execute_command/apply_spec.ex +++ b/apps/language_server/lib/language_server/providers/execute_command/apply_spec.ex @@ -56,8 +56,8 @@ defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.ApplySpec do formatted = try do target_line_length = - case SourceFile.formatter_opts(uri) do - {:ok, opts} -> Keyword.get(opts, :line_length, @default_target_line_length) + case SourceFile.formatter_for(uri) do + {:ok, {_, opts}} -> Keyword.get(opts, :line_length, @default_target_line_length) :error -> @default_target_line_length end @@ -78,6 +78,7 @@ defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.ApplySpec do "label" => "Add @spec to #{mod}.#{fun}/#{arity}", "edit" => %{ "changes" => %{ + # we don't care about utf16 positions here as we send 0 uri => [%{"range" => range(line - 1, 0, line - 1, 0), "newText" => formatted}] } } diff --git a/apps/language_server/lib/language_server/providers/execute_command/get_ex_unit_tests_in_file.ex b/apps/language_server/lib/language_server/providers/execute_command/get_ex_unit_tests_in_file.ex new file mode 100644 index 000000000..dfc167e7b --- /dev/null +++ b/apps/language_server/lib/language_server/providers/execute_command/get_ex_unit_tests_in_file.ex @@ -0,0 +1,18 @@ +defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.GetExUnitTestsInFile do + alias ElixirLS.LanguageServer.{SourceFile, ExUnitTestTracer} + @behaviour ElixirLS.LanguageServer.Providers.ExecuteCommand + + @impl ElixirLS.LanguageServer.Providers.ExecuteCommand + def execute([uri], _state) do + if Version.match?(System.version(), ">= 1.13.0") do + path = SourceFile.Path.from_uri(uri) + + case ExUnitTestTracer.get_tests(path) do + {:ok, tests} -> {:ok, tests} + {:error, reason} -> {:error, :server_error, inspect(reason)} + end + else + {:error, :server_error, "This feature requires elixir >= 1.13"} + end + end +end diff --git a/apps/language_server/lib/language_server/providers/execute_command/mix_clean.ex b/apps/language_server/lib/language_server/providers/execute_command/mix_clean.ex new file mode 100644 index 000000000..4edaa4022 --- /dev/null +++ b/apps/language_server/lib/language_server/providers/execute_command/mix_clean.ex @@ -0,0 +1,11 @@ +defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.MixClean do + @behaviour ElixirLS.LanguageServer.Providers.ExecuteCommand + + @impl ElixirLS.LanguageServer.Providers.ExecuteCommand + def execute([clean_deps?], _state) do + case ElixirLS.LanguageServer.Build.clean(clean_deps?) do + :ok -> {:ok, %{}} + {:error, reason} -> {:error, :server_error, inspect(reason)} + end + end +end diff --git a/apps/language_server/lib/language_server/providers/formatting.ex b/apps/language_server/lib/language_server/providers/formatting.ex index 12b413d94..11d5b3f1e 100644 --- a/apps/language_server/lib/language_server/providers/formatting.ex +++ b/apps/language_server/lib/language_server/providers/formatting.ex @@ -3,12 +3,13 @@ defmodule ElixirLS.LanguageServer.Providers.Formatting do alias ElixirLS.LanguageServer.Protocol.TextEdit alias ElixirLS.LanguageServer.SourceFile - def format(%SourceFile{} = source_file, uri, project_dir) when is_binary(project_dir) do + def format(%SourceFile{} = source_file, uri = "file:" <> _, project_dir) + when is_binary(project_dir) do if can_format?(uri, project_dir) do - case SourceFile.formatter_opts(uri) do - {:ok, opts} -> + case SourceFile.formatter_for(uri) do + {:ok, {formatter, opts}} -> if should_format?(uri, project_dir, opts[:inputs]) do - do_format(source_file, opts) + do_format(source_file, formatter, opts) else {:ok, []} end @@ -25,12 +26,13 @@ defmodule ElixirLS.LanguageServer.Providers.Formatting do end end - def format(%SourceFile{} = source_file, _uri, nil) do - do_format(source_file) + # if project_dir is not set or schema is not file: we format with default options + def format(%SourceFile{} = source_file, _uri, _project_dir) do + do_format(source_file, nil, []) end - defp do_format(%SourceFile{text: text}, opts \\ []) do - formatted = IO.iodata_to_binary([Code.format_string!(text, opts), ?\n]) + defp do_format(%SourceFile{text: text}, formatter, opts) do + formatted = get_formatted(text, formatter, opts) response = text @@ -43,10 +45,18 @@ defmodule ElixirLS.LanguageServer.Providers.Formatting do {:error, :internal_error, "Unable to format due to syntax error"} end + defp get_formatted(text, formatter, _) when is_function(formatter) do + formatter.(text) + end + + defp get_formatted(text, _, opts) do + IO.iodata_to_binary([Code.format_string!(text, opts), ?\n]) + end + # If in an umbrella project, the cwd might be set to a sub-app if it's being compiled. This is # fine if the file we're trying to format is in that app. Otherwise, we return an error. defp can_format?(file_uri = "file:" <> _, project_dir) do - file_path = file_uri |> SourceFile.abs_path_from_uri() + file_path = SourceFile.Path.absolute_from_uri(file_uri) String.starts_with?(file_path, Path.absname(project_dir)) or String.starts_with?(file_path, File.cwd!()) @@ -55,7 +65,7 @@ defmodule ElixirLS.LanguageServer.Providers.Formatting do defp can_format?(_uri, _project_dir), do: false defp should_format?(file_uri, project_dir, inputs) when is_list(inputs) do - file_path = file_uri |> SourceFile.abs_path_from_uri() + file_path = SourceFile.Path.absolute_from_uri(file_uri) formatter_dir = find_formatter_dir(project_dir, Path.dirname(file_path)) Enum.any?(inputs, fn input_glob -> diff --git a/apps/language_server/lib/language_server/providers/hover.ex b/apps/language_server/lib/language_server/providers/hover.ex index 53d295aad..dd18393f1 100644 --- a/apps/language_server/lib/language_server/providers/hover.ex +++ b/apps/language_server/lib/language_server/providers/hover.ex @@ -1,5 +1,6 @@ defmodule ElixirLS.LanguageServer.Providers.Hover do alias ElixirLS.LanguageServer.SourceFile + import ElixirLS.LanguageServer.Protocol @moduledoc """ Hover provider utilizing Elixir Sense @@ -17,16 +18,18 @@ defmodule ElixirLS.LanguageServer.Providers.Hover do |> Enum.map(fn x -> "lib/#{x}/lib" end) def hover(text, line, character, project_dir) do + {line, character} = SourceFile.lsp_position_to_elixir(text, {line, character}) + response = - case ElixirSense.docs(text, line + 1, character + 1) do + case ElixirSense.docs(text, line, character) do %{subject: ""} -> nil - %{subject: subject, docs: docs} -> - line_text = Enum.at(SourceFile.lines(text), line) - range = highlight_range(line_text, line, character, subject) + %{subject: subject, docs: docs, actual_subject: actual_subject} -> + line_text = Enum.at(SourceFile.lines(text), line - 1) + range = highlight_range(line_text, line - 1, character - 1, subject) - %{"contents" => contents(docs, subject, project_dir), "range" => range} + %{"contents" => contents(docs, actual_subject, project_dir), "range" => range} end {:ok, response} @@ -45,10 +48,12 @@ defmodule ElixirLS.LanguageServer.Providers.Hover do Enum.find_value(regex_ranges, fn [{start, length}] when start <= character and character <= start + length -> - %{ - "start" => %{"line" => line, "character" => start}, - "end" => %{"line" => line, "character" => start + length} - } + range( + line, + SourceFile.elixir_character_to_lsp(line_text, start + 1), + line, + SourceFile.elixir_character_to_lsp(line_text, start + 1 + length) + ) _ -> nil @@ -85,7 +90,7 @@ defmodule ElixirLS.LanguageServer.Providers.Hover do cond do erlang_module?(subject) -> - # erlang moudle is not support now. + # TODO erlang module is currently not supported "" true -> @@ -131,12 +136,16 @@ defmodule ElixirLS.LanguageServer.Providers.Hover do end defp dep_name(root_mod_name, project_dir) do - s = root_mod_name |> source() - - cond do - third_dep?(s, project_dir) -> third_dep_name(s, project_dir) - builtin?(s) -> builtin_dep_name(s) - true -> "" + if not elixir_mod_exported?(root_mod_name) do + "" + else + s = root_mod_name |> source() + + cond do + third_dep?(s, project_dir) -> third_dep_name(s, project_dir) + builtin?(s) -> builtin_dep_name(s) + true -> "" + end end end @@ -149,6 +158,12 @@ defmodule ElixirLS.LanguageServer.Providers.Hover do dep.__info__(:compile) |> Keyword.get(:source) |> List.to_string() end + defp elixir_mod_exported?(mod_name) do + ("Elixir." <> mod_name) |> String.to_atom() |> function_exported?(:__info__, 1) + end + + defp third_dep?(_source, nil), do: false + defp third_dep?(source, project_dir) do prefix = project_dir <> "/deps" String.starts_with?(source, prefix) diff --git a/apps/language_server/lib/language_server/providers/implementation.ex b/apps/language_server/lib/language_server/providers/implementation.ex index 746235a54..0cf874375 100644 --- a/apps/language_server/lib/language_server/providers/implementation.ex +++ b/apps/language_server/lib/language_server/providers/implementation.ex @@ -3,11 +3,12 @@ defmodule ElixirLS.LanguageServer.Providers.Implementation do Go-to-implementation provider utilizing Elixir Sense """ - alias ElixirLS.LanguageServer.Protocol + alias ElixirLS.LanguageServer.{Protocol, SourceFile} def implementation(uri, text, line, character) do - locations = ElixirSense.implementations(text, line + 1, character + 1) - results = for location <- locations, do: Protocol.Location.new(location, uri) + {line, character} = SourceFile.lsp_position_to_elixir(text, {line, character}) + locations = ElixirSense.implementations(text, line, character) + results = for location <- locations, do: Protocol.Location.new(location, uri, text) {:ok, results} end diff --git a/apps/language_server/lib/language_server/providers/on_type_formatting.ex b/apps/language_server/lib/language_server/providers/on_type_formatting.ex index 3b374ca8f..6665b1376 100644 --- a/apps/language_server/lib/language_server/providers/on_type_formatting.ex +++ b/apps/language_server/lib/language_server/providers/on_type_formatting.ex @@ -10,7 +10,8 @@ defmodule ElixirLS.LanguageServer.Providers.OnTypeFormatting do alias ElixirLS.LanguageServer.SourceFile import ElixirLS.LanguageServer.Protocol - def format(%SourceFile{} = source_file, line, character, "\n", _options) do + def format(%SourceFile{} = source_file, line, character, "\n", _options) when line >= 1 do + # we don't care about utf16 positions here as we only pass character back to client lines = SourceFile.lines(source_file) prev_line = Enum.at(lines, line - 1) @@ -69,6 +70,7 @@ defmodule ElixirLS.LanguageServer.Providers.OnTypeFormatting do # In VS Code, currently, the cursor jumps strangely if the current line is blank and we try to # insert a newline at the current position, so unfortunately, we have to check for that. defp insert_end_edit(indentation, line, character, insert_on_next_line?) do + # we don't care about utf16 positions here as we either use 0 or what the client sent if insert_on_next_line? do {range(line + 1, 0, line + 1, 0), "#{indentation}end\n"} else diff --git a/apps/language_server/lib/language_server/providers/references.ex b/apps/language_server/lib/language_server/providers/references.ex index 293982e02..adcf3d591 100644 --- a/apps/language_server/lib/language_server/providers/references.ex +++ b/apps/language_server/lib/language_server/providers/references.ex @@ -11,26 +11,36 @@ defmodule ElixirLS.LanguageServer.Providers.References do """ alias ElixirLS.LanguageServer.{SourceFile, Build} + import ElixirLS.LanguageServer.Protocol def references(text, uri, line, character, _include_declaration) do + {line, character} = SourceFile.lsp_position_to_elixir(text, {line, character}) + Build.with_build_lock(fn -> - ElixirSense.references(text, line + 1, character + 1) + trace = ElixirLS.LanguageServer.Tracer.get_trace() + + ElixirSense.references(text, line, character, trace) |> Enum.map(fn elixir_sense_reference -> elixir_sense_reference - |> build_reference(uri) - |> build_loc() + |> build_reference(uri, text) end) - |> Enum.filter(&has_uri?/1) end) end - defp build_reference(ref, current_file_uri) do + defp build_reference(ref, current_file_uri, current_file_text) do + text = get_text(ref, current_file_text) + + {start_line, start_column} = + SourceFile.elixir_position_to_lsp(text, {ref.range.start.line, ref.range.start.column}) + + {end_line, end_column} = + SourceFile.elixir_position_to_lsp(text, {ref.range.end.line, ref.range.end.column}) + + range = range(start_line, start_column, end_line, end_column) + %{ - range: %{ - start: %{line: ref.range.start.line, column: ref.range.start.column}, - end: %{line: ref.range.end.line, column: ref.range.end.column} - }, - uri: build_uri(ref, current_file_uri) + "range" => range, + "uri" => build_uri(ref, current_file_uri) } end @@ -41,26 +51,14 @@ defmodule ElixirLS.LanguageServer.Providers.References do nil -> current_file_uri # ElixirSense returns a plain path (e.g. "/home/bob/my_app/lib/a.ex") as # the "uri" so we convert it to an actual uri - path when is_binary(path) -> SourceFile.path_to_uri(path) - _ -> nil + path when is_binary(path) -> SourceFile.Path.to_uri(path) end end - defp has_uri?(reference), do: !is_nil(reference["uri"]) - - defp build_loc(reference) do - # Adjust for ElixirSense 1-based indexing - line_start = reference.range.start.line - 1 - line_end = reference.range.end.line - 1 - column_start = reference.range.start.column - 1 - column_end = reference.range.end.column - 1 - - %{ - "uri" => reference.uri, - "range" => %{ - "start" => %{"line" => line_start, "character" => column_start}, - "end" => %{"line" => line_end, "character" => column_end} - } - } + def get_text(elixir_sense_ref, current_file_text) do + case elixir_sense_ref.uri do + nil -> current_file_text + path when is_binary(path) -> File.read!(path) + end end end diff --git a/apps/language_server/lib/language_server/providers/rename.ex b/apps/language_server/lib/language_server/providers/rename.ex index 2897e9d9b..24f815b3a 100644 --- a/apps/language_server/lib/language_server/providers/rename.ex +++ b/apps/language_server/lib/language_server/providers/rename.ex @@ -8,25 +8,27 @@ defmodule ElixirLS.LanguageServer.Providers.Rename do alias ElixirLS.LanguageServer.SourceFile def rename(%SourceFile{} = source_file, start_uri, line, character, new_name) do + trace = ElixirLS.LanguageServer.Tracer.get_trace() + edits = with char_ident when not is_nil(char_ident) <- get_char_ident(source_file.text, line, character), %ElixirSense.Location{} = definition <- ElixirSense.definition(source_file.text, line, character), - references <- ElixirSense.references(source_file.text, line, character) do + references <- ElixirSense.references(source_file.text, line, character, trace) do length_old = length(char_ident) definition_references = case definition do %{file: nil, type: :function} -> parse_definition_source_code(source_file.text) - |> get_all_fn_header_positions(char_ident) + |> get_all_fn_header_positions(char_ident, definition) |> positions_to_references(start_uri, length_old) %{file: separate_file_path, type: :function} -> parse_definition_source_code(definition) - |> get_all_fn_header_positions(char_ident) - |> positions_to_references(SourceFile.path_to_uri(separate_file_path), length_old) + |> get_all_fn_header_positions(char_ident, definition) + |> positions_to_references(SourceFile.Path.to_uri(separate_file_path), length_old) _ -> positions_to_references( @@ -36,7 +38,7 @@ defmodule ElixirLS.LanguageServer.Providers.Rename do ) end - definition_references ++ repack_references(references, start_uri) + Enum.uniq(definition_references ++ repack_references(references, start_uri)) else _ -> [] @@ -49,7 +51,7 @@ defmodule ElixirLS.LanguageServer.Providers.Rename do %{ "textDocument" => %{ "uri" => uri, - "version" => source_file.version + 1 + "version" => nil }, "edits" => Enum.map(edits, fn edit -> @@ -66,25 +68,27 @@ defmodule ElixirLS.LanguageServer.Providers.Rename do with %{ begin: {start_line, start_col}, end: {end_line, end_col}, - context: {context, char_ident} - } - when context in [:local_or_var, :local_call] <- - Code.Fragment.surround_context(source_file.text, {line, character}) do + char_ident: char_ident + } = res + when not is_nil(res) <- + get_begin_end_and_char_ident(source_file.text, line, character) do %{ range: adjust_range(start_line, start_col, end_line, end_col), placeholder: to_string(char_ident) } else _ -> - # Not a variable or local call, skipping for now + # Not a variable or function call, skipping nil end {:ok, result} end - defp repack_references(references, uri) do - for reference <- references do + defp repack_references(references, start_uri) do + Enum.map(references, fn reference -> + uri = if reference.uri, do: SourceFile.Path.to_uri(reference.uri), else: start_uri + %{ uri: uri, range: %{ @@ -95,21 +99,27 @@ defmodule ElixirLS.LanguageServer.Providers.Rename do } } } - end + end) end defp parse_definition_source_code(%{file: file}) do - ElixirSense.Core.Parser.parse_file(file, true, true, 0) + ElixirSense.Core.Parser.parse_file(file, true, true, nil) end defp parse_definition_source_code(source_text) when is_binary(source_text) do - ElixirSense.Core.Parser.parse_string(source_text, true, true, 0) + ElixirSense.Core.Parser.parse_string(source_text, true, true, nil) end - defp get_all_fn_header_positions(parsed_source, char_ident) do + defp get_all_fn_header_positions( + parsed_source, + definition_name, + %{column: column, line: line} = _definition + ) do parsed_source.mods_funs_to_positions |> Map.filter(fn - {{_, fn_name, _}, _} -> Atom.to_charlist(fn_name) == char_ident + {{_, fn_name, fn_arity}, %{positions: fn_positions}} -> + Atom.to_charlist(fn_name) === definition_name and not is_nil(fn_arity) and + Enum.member?(fn_positions, {line, column}) end) |> Enum.flat_map(fn {_, %{positions: positions}} -> positions end) |> Enum.uniq() @@ -134,10 +144,23 @@ defmodule ElixirLS.LanguageServer.Providers.Rename do end defp get_char_ident(text, line, character) do + case get_begin_end_and_char_ident(text, line, character) do + nil -> nil + %{char_ident: char_ident} -> char_ident + end + end + + defp get_begin_end_and_char_ident(text, line, character) do case Code.Fragment.surround_context(text, {line, character}) do - %{context: {context, char_ident}} when context in [:local_or_var, :local_call] -> char_ident - %{context: {:dot, _, char_ident}} -> char_ident - _ -> nil + %{begin: begin, end: the_end, context: {context, char_ident}} + when context in [:local_or_var, :local_call] -> + %{begin: begin, end: the_end, char_ident: char_ident} + + %{begin: begin, end: the_end, context: {:dot, _, char_ident}} -> + %{begin: begin, end: the_end, char_ident: char_ident} + + _ -> + nil end end end diff --git a/apps/language_server/lib/language_server/providers/signature_help.ex b/apps/language_server/lib/language_server/providers/signature_help.ex index 296312d49..7f9a615e5 100644 --- a/apps/language_server/lib/language_server/providers/signature_help.ex +++ b/apps/language_server/lib/language_server/providers/signature_help.ex @@ -4,8 +4,10 @@ defmodule ElixirLS.LanguageServer.Providers.SignatureHelp do def trigger_characters(), do: ["(", ","] def signature(%SourceFile{} = source_file, line, character) do + {line, character} = SourceFile.lsp_position_to_elixir(source_file.text, {line, character}) + response = - case ElixirSense.signature(source_file.text, line + 1, character + 1) do + case ElixirSense.signature(source_file.text, line, character) do %{active_param: active_param, signatures: signatures} -> %{ "activeSignature" => 0, diff --git a/apps/language_server/lib/language_server/providers/workspace_symbols.ex b/apps/language_server/lib/language_server/providers/workspace_symbols.ex index ef28ff277..582a7165d 100644 --- a/apps/language_server/lib/language_server/providers/workspace_symbols.ex +++ b/apps/language_server/lib/language_server/providers/workspace_symbols.ex @@ -9,7 +9,7 @@ defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbols do alias ElixirLS.LanguageServer.ErlangSourceFile alias ElixirLS.LanguageServer.SourceFile alias ElixirLS.LanguageServer.Providers.SymbolUtils - alias ElixirLS.LanguageServer.JsonRpc + require Logger @arity_suffix_regex ~r/\/\d+$/ @@ -73,7 +73,7 @@ defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbols do end end - @spec notify_uris_modified([String.t()]) :: :ok + @spec notify_uris_modified([String.t()]) :: :ok | nil def notify_uris_modified(uris, server \\ __MODULE__, override_test_mode \\ false) do unless Application.get_env(:language_server, :test_mode) && not override_test_mode do GenServer.cast(server, {:uris_modified, uris}) @@ -122,7 +122,7 @@ defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbols do callbacks_indexed: false } ) do - JsonRpc.log_message(:info, "[ElixirLS WorkspaceSymbols] Indexing...") + Logger.info("[ElixirLS WorkspaceSymbols] Indexing...") module_paths = :code.all_loaded() @@ -133,7 +133,7 @@ defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbols do do: {module, path} end) - JsonRpc.log_message(:info, "[ElixirLS WorkspaceSymbols] Module discovery complete") + Logger.info("[ElixirLS WorkspaceSymbols] Module discovery complete") index(module_paths) @@ -149,21 +149,18 @@ defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbols do modified_uris: modified_uris = [_ | _] } = state ) do - JsonRpc.log_message(:info, "[ElixirLS WorkspaceSymbols] Updating index...") + Logger.info("[ElixirLS WorkspaceSymbols] Updating index...") module_paths = :code.all_loaded() |> process_chunked(fn chunk -> for {module, beam_file} <- chunk, path = find_module_path(module, beam_file), - SourceFile.path_to_uri(path) in modified_uris, + SourceFile.Path.to_uri(path) in modified_uris, do: {module, path} end) - JsonRpc.log_message( - :info, - "[ElixirLS WorkspaceSymbols] #{length(module_paths)} modules need reindexing" - ) + Logger.info("[ElixirLS WorkspaceSymbols] #{length(module_paths)} modules need reindexing") index(module_paths) @@ -428,10 +425,7 @@ defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbols do send(self, {:indexing_complete, key, results}) - JsonRpc.log_message( - :info, - "[ElixirLS WorkspaceSymbols] #{length(results)} #{key} added to index" - ) + Logger.info("[ElixirLS WorkspaceSymbols] #{length(results)} #{key} added to index") end) :ok @@ -487,7 +481,7 @@ defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbols do kind: @symbol_codes |> Map.fetch!(key), name: symbol_name(key, symbol), location: %{ - uri: SourceFile.path_to_uri(path), + uri: SourceFile.Path.to_uri(path), range: build_range(location) } } @@ -512,15 +506,18 @@ defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbols do @spec build_range(nil | erl_location_t) :: range_t defp build_range(nil) do + # we don't care about utf16 positions here as we send 0 %{ start: %{line: 0, character: 0}, end: %{line: 1, character: 0} } end + # it's not worth to present column info here defp build_range({line, _column}), do: build_range(line) defp build_range(line) do + # we don't care about utf16 positions here as we send 0 %{ start: %{line: max(line - 1, 0), character: 0}, end: %{line: line, character: 0} diff --git a/apps/language_server/lib/language_server/server.ex b/apps/language_server/lib/language_server/server.ex index 4e16c0920..37332b45b 100644 --- a/apps/language_server/lib/language_server/server.ex +++ b/apps/language_server/lib/language_server/server.ex @@ -16,7 +16,8 @@ defmodule ElixirLS.LanguageServer.Server do """ use GenServer - alias ElixirLS.LanguageServer.{SourceFile, Build, Protocol, JsonRpc, Dialyzer} + require Logger + alias ElixirLS.LanguageServer.{SourceFile, Build, Protocol, JsonRpc, Dialyzer, Diagnostics} alias ElixirLS.LanguageServer.Providers.{ Completion, @@ -32,10 +33,13 @@ defmodule ElixirLS.LanguageServer.Server do OnTypeFormatting, CodeLens, ExecuteCommand, - FoldingRange + FoldingRange, + CodeAction } alias ElixirLS.Utils.Launch + alias ElixirLS.LanguageServer.Tracer + alias ElixirLS.Utils.MixfileHelpers use Protocol @@ -50,7 +54,6 @@ defmodule ElixirLS.LanguageServer.Server do build_diagnostics: [], dialyzer_diagnostics: [], needs_build?: false, - load_all_modules?: false, build_running?: false, analysis_ready?: false, received_shutdown?: false, @@ -59,6 +62,7 @@ defmodule ElixirLS.LanguageServer.Server do source_files: %{}, awaiting_contracts: [], supports_dynamic: false, + mix_project?: false, no_mixfile_warned?: false ] @@ -135,10 +139,7 @@ defmodule ElixirLS.LanguageServer.Server do def handle_call({:suggest_contracts, uri = "file:" <> _}, from, state = %__MODULE__{}) do case state do %{analysis_ready?: true, source_files: %{^uri => %{dirty?: false}}} -> - abs_path = - uri - |> SourceFile.abs_path_from_uri() - + abs_path = SourceFile.Path.absolute_from_uri(uri) {:reply, Dialyzer.suggest_contracts([abs_path]), state} %{source_files: %{^uri => _}} -> @@ -208,8 +209,7 @@ defmodule ElixirLS.LanguageServer.Server do state = case state do %{settings: nil} -> - JsonRpc.show_message( - :info, + Logger.warn( "Did not receive workspace/didChangeConfiguration notification after 5 seconds. " <> "Using default settings." ) @@ -233,7 +233,7 @@ defmodule ElixirLS.LanguageServer.Server do state = case reason do :normal -> state - _ -> handle_build_result(:error, [Build.exception_to_diagnostic(reason)], state) + _ -> handle_build_result(:error, [Diagnostics.exception_to_diagnostic(reason)], state) end if reason == :normal do @@ -281,10 +281,7 @@ defmodule ElixirLS.LanguageServer.Server do %{state | requests: Map.delete(requests, id)} _ -> - JsonRpc.log_message( - :warning, - "Received $/cancelRequest for unknown request id: #{inspect(id)}" - ) + Logger.warn("Received $/cancelRequest for unknown request id: #{inspect(id)}") state end @@ -324,8 +321,7 @@ defmodule ElixirLS.LanguageServer.Server do if Map.has_key?(state.source_files, uri) do # An open notification must not be sent more than once without a corresponding # close notification send before - JsonRpc.log_message( - :warning, + Logger.warn( "Received textDocument/didOpen for file that is already open. Received uri: #{inspect(uri)}" ) @@ -333,7 +329,7 @@ defmodule ElixirLS.LanguageServer.Server do else source_file = %SourceFile{text: text, version: version} - Build.publish_file_diagnostics( + Diagnostics.publish_file_diagnostics( uri, state.build_diagnostics ++ state.dialyzer_diagnostics, source_file @@ -346,8 +342,7 @@ defmodule ElixirLS.LanguageServer.Server do defp handle_notification(did_close(uri), state = %__MODULE__{}) do if not Map.has_key?(state.source_files, uri) do # A close notification requires a previous open notification to be sent - JsonRpc.log_message( - :warning, + Logger.warn( "Received textDocument/didClose for file that is not open. Received uri: #{inspect(uri)}" ) @@ -368,8 +363,7 @@ defmodule ElixirLS.LanguageServer.Server do # The source file was not marked as open either due to a bug in the # client or a restart of the server. So just ignore the message and do # not update the state - JsonRpc.log_message( - :warning, + Logger.warn( "Received textDocument/didChange for file that is not open. Received uri: #{inspect(uri)}" ) @@ -384,8 +378,7 @@ defmodule ElixirLS.LanguageServer.Server do defp handle_notification(did_save(uri), state = %__MODULE__{}) do if not Map.has_key?(state.source_files, uri) do - JsonRpc.log_message( - :warning, + Logger.warn( "Received textDocument/didSave for file that is not open. Received uri: #{inspect(uri)}" ) @@ -406,13 +399,28 @@ defmodule ElixirLS.LanguageServer.Server do needs_build = Enum.any?(changes, fn %{"uri" => uri = "file:" <> _, "type" => type} -> - path = SourceFile.path_from_uri(uri) + path = SourceFile.Path.from_uri(uri) + + relative_path = Path.relative_to(path, state.project_dir) + first_path_segment = relative_path |> Path.split() |> hd - Path.extname(path) in (additional_watched_extensions ++ @default_watched_extensions) and + first_path_segment not in [".elixir_ls", "_build"] and + Path.extname(path) in (additional_watched_extensions ++ @default_watched_extensions) and (type in [1, 3] or not Map.has_key?(state.source_files, uri) or state.source_files[uri].dirty?) end) + # TODO remove uniq when duplicated subscriptions from vscode plugin are fixed + deleted_paths = + for change <- changes, + change["type"] == 3, + uniq: true, + do: SourceFile.Path.from_uri(change["uri"]) + + for path <- deleted_paths do + Tracer.notify_file_deleted(path) + end + source_files = changes |> Enum.reduce(state.source_files, fn @@ -424,7 +432,7 @@ defmodule ElixirLS.LanguageServer.Server do # file created/updated - set dirty flag to false if file contents are equal case acc[uri] do %SourceFile{text: source_file_text, dirty?: true} = source_file -> - case File.read(SourceFile.path_from_uri(uri)) do + case File.read(SourceFile.Path.from_uri(uri)) do {:ok, ^source_file_text} -> Map.put(acc, uri, %SourceFile{source_file | dirty?: false}) @@ -432,7 +440,7 @@ defmodule ElixirLS.LanguageServer.Server do acc {:error, reason} -> - JsonRpc.log_message(:warning, "Unable to read #{uri}: #{inspect(reason)}") + Logger.warn("Unable to read #{uri}: #{inspect(reason)}") # keep dirty if read fails acc end @@ -445,6 +453,7 @@ defmodule ElixirLS.LanguageServer.Server do state = %{state | source_files: source_files} + # TODO remove uniq when duplicated subscriptions from vscode plugin are fixed changes |> Enum.map(& &1["uri"]) |> Enum.uniq() @@ -459,7 +468,7 @@ defmodule ElixirLS.LanguageServer.Server do end defp handle_notification(packet, state = %__MODULE__{}) do - JsonRpc.log_message(:warning, "Received unmatched notification: #{inspect(packet)}") + Logger.warn("Received unmatched notification: #{inspect(packet)}") state end @@ -519,9 +528,9 @@ defmodule ElixirLS.LanguageServer.Server do state = case root_uri do "file://" <> _ -> - root_path = SourceFile.abs_path_from_uri(root_uri) + root_path = SourceFile.Path.absolute_from_uri(root_uri) File.cd!(root_path) - cwd_uri = SourceFile.path_to_uri(File.cwd!()) + cwd_uri = SourceFile.Path.to_uri(File.cwd!()) %{state | root_uri: cwd_uri} nil -> @@ -671,14 +680,20 @@ defmodule ElixirLS.LanguageServer.Server do !!get_in(state.client_capabilities, ["textDocument", "signatureHelp"]) locals_without_parens = - case SourceFile.formatter_opts(uri) do - {:ok, opts} -> Keyword.get(opts, :locals_without_parens, []) + case SourceFile.formatter_for(uri) do + {:ok, {_, opts}} -> Keyword.get(opts, :locals_without_parens, []) :error -> [] end |> MapSet.new() signature_after_complete = Map.get(state.settings || %{}, "signatureAfterComplete", true) + path = + case uri do + "file:" <> _ -> SourceFile.Path.from_uri(uri) + _ -> nil + end + fun = fn -> Completion.completion(source_file.text, line, character, snippets_supported: snippets_supported, @@ -686,7 +701,8 @@ defmodule ElixirLS.LanguageServer.Server do tags_supported: tags_supported, signature_help_supported: signature_help_supported, locals_without_parens: locals_without_parens, - signature_after_complete: signature_after_complete + signature_after_complete: signature_after_complete, + file_path: path ) end @@ -765,7 +781,7 @@ defmodule ElixirLS.LanguageServer.Server do fn -> case ExecuteCommand.execute(command, args, state) do {:error, :invalid_request, _msg} = res -> - JsonRpc.log_message(:warning, "Unmatched request: #{inspect(req)}") + Logger.warn("Unmatched request: #{inspect(req)}") res other -> @@ -785,13 +801,17 @@ defmodule ElixirLS.LanguageServer.Server do end end + defp handle_request(code_action_req(_id, uri, diagnostics), state = %__MODULE__{}) do + {:async, fn -> CodeAction.code_actions(uri, diagnostics) end, state} + end + defp handle_request(%{"method" => "$/" <> _}, state = %__MODULE__{}) do # "$/" requests that the server doesn't support must return method_not_found {:error, :method_not_found, nil, state} end defp handle_request(req, state = %__MODULE__{}) do - JsonRpc.log_message(:warning, "Unmatched request: #{inspect(req)}") + Logger.warn("Unmatched request: #{inspect(req)}") {:error, :invalid_request, nil, state} end @@ -837,7 +857,8 @@ defmodule ElixirLS.LanguageServer.Server do "workspace" => %{ "workspaceFolders" => %{"supported" => false, "changeNotifications" => false} }, - "foldingRangeProvider" => true + "foldingRangeProvider" => true, + "codeActionProvider" => true } end @@ -861,16 +882,16 @@ defmodule ElixirLS.LanguageServer.Server do defp get_test_code_lenses( state = %__MODULE__{project_dir: project_dir}, - uri, + "file:" <> _ = uri, source_file, true = _enabled, true = _umbrella ) when is_binary(project_dir) do - file_path = SourceFile.path_from_uri(uri) + file_path = SourceFile.Path.from_uri(uri) Mix.Project.apps_paths() - |> Enum.find(fn {_app, app_path} -> String.contains?(file_path, app_path) end) + |> Enum.find(fn {_app, app_path} -> under_app?(file_path, project_dir, app_path) end) |> case do nil -> {:ok, []} @@ -886,14 +907,14 @@ defmodule ElixirLS.LanguageServer.Server do defp get_test_code_lenses( %__MODULE__{project_dir: project_dir}, - uri, + "file:" <> _ = uri, source_file, true = _enabled, false = _umbrella ) when is_binary(project_dir) do try do - file_path = SourceFile.path_from_uri(uri) + file_path = SourceFile.Path.from_uri(uri) if is_test_file?(file_path) do CodeLens.test_code_lens(uri, source_file.text, project_dir) @@ -930,29 +951,33 @@ defmodule ElixirLS.LanguageServer.Server do |> Enum.any?(&(&1 == file_path)) end + defp under_app?(file_path, project_dir, app_path) do + file_path_list = file_path |> Path.relative_to(project_dir) |> Path.split() + app_path_list = app_path |> Path.split() + + List.starts_with?(file_path_list, app_path_list) + end + # Build defp trigger_build(state = %__MODULE__{project_dir: project_dir}) do + build_automatically = Map.get(state.settings || %{}, "autoBuild", true) + cond do not build_enabled?(state) -> state - not state.build_running? -> + not state.build_running? and build_automatically -> fetch_deps? = Map.get(state.settings || %{}, "fetchDeps", false) - {_pid, build_ref} = - Build.build(self(), project_dir, - fetch_deps?: fetch_deps?, - load_all_modules?: state.load_all_modules? - ) + {_pid, build_ref} = Build.build(self(), project_dir, fetch_deps?: fetch_deps?) %__MODULE__{ state | build_ref: build_ref, needs_build?: false, build_running?: true, - analysis_ready?: false, - load_all_modules?: false + analysis_ready?: false } true -> @@ -1024,7 +1049,7 @@ defmodule ElixirLS.LanguageServer.Server do # If these results were triggered by the most recent build and files are not dirty, then we know # we're up to date and can release spec suggestions to the code lens provider if build_ref == state.build_ref do - JsonRpc.log_message(:info, "Dialyzer analysis is up to date") + Logger.info("Dialyzer analysis is up to date") {dirty, not_dirty} = state.awaiting_contracts @@ -1034,14 +1059,14 @@ defmodule ElixirLS.LanguageServer.Server do contracts_by_file = not_dirty - |> Enum.map(fn {_from, uri} -> SourceFile.path_from_uri(uri) end) + |> Enum.map(fn {_from, uri} -> SourceFile.Path.from_uri(uri) end) |> Dialyzer.suggest_contracts() |> Enum.group_by(fn {file, _, _, _, _} -> file end) for {from, uri} <- not_dirty do contracts = contracts_by_file - |> Map.get(SourceFile.path_from_uri(uri), []) + |> Map.get(SourceFile.Path.from_uri(uri), []) GenServer.reply(from, contracts) end @@ -1060,13 +1085,32 @@ defmodule ElixirLS.LanguageServer.Server do Dialyzer.check_support() == :ok and build_enabled?(state) and state.dialyzer_sup != nil end + defp safely_read_file(file) do + case File.read(file) do + {:ok, text} -> + text + + {:error, reason} -> + if reason != :enoent do + Logger.warn("Couldn't read file #{file}: #{inspect(reason)}") + end + + nil + end + end + defp publish_diagnostics(new_diagnostics, old_diagnostics, source_files) do files = Enum.uniq(Enum.map(new_diagnostics, & &1.file) ++ Enum.map(old_diagnostics, & &1.file)) for file <- files, - uri = SourceFile.path_to_uri(file), - do: Build.publish_file_diagnostics(uri, new_diagnostics, Map.get(source_files, uri)) + uri = SourceFile.Path.to_uri(file), + do: + Diagnostics.publish_file_diagnostics( + uri, + new_diagnostics, + Map.get_lazy(source_files, uri, fn -> safely_read_file(file) end) + ) end defp show_version_warnings do @@ -1080,7 +1124,7 @@ defmodule ElixirLS.LanguageServer.Server do case Dialyzer.check_support() do :ok -> :ok - {:error, msg} -> JsonRpc.show_message(:info, msg) + {:error, msg} -> JsonRpc.show_message(:warning, msg) end :ok @@ -1105,7 +1149,9 @@ defmodule ElixirLS.LanguageServer.Server do |> set_dialyzer_enabled(enable_dialyzer) |> add_watched_extensions(additional_watched_extensions) + maybe_rebuild(state) state = create_gitignore(state) + Tracer.set_project_dir(state.project_dir) trigger_build(%{state | settings: settings}) end @@ -1124,10 +1170,7 @@ defmodule ElixirLS.LanguageServer.Server do :ok other -> - JsonRpc.log_message( - :error, - "client/registerCapability returned: #{inspect(other)}" - ) + Logger.error("client/registerCapability returned: #{inspect(other)}") end state @@ -1158,8 +1201,11 @@ defmodule ElixirLS.LanguageServer.Server do else JsonRpc.show_message( :warning, - "You must restart ElixirLS after changing environment variables" + "Environment variables have changed. ElixirLS needs to restart" ) + + Process.sleep(5000) + System.halt(1) end state @@ -1171,7 +1217,10 @@ defmodule ElixirLS.LanguageServer.Server do if is_nil(prev_env) or env == prev_env do Mix.env(String.to_atom(env)) else - JsonRpc.show_message(:warning, "You must restart ElixirLS after changing Mix env") + JsonRpc.show_message(:warning, "Mix env change detected. ElixirLS will restart.") + + Process.sleep(5000) + System.halt(1) end state @@ -1191,7 +1240,10 @@ defmodule ElixirLS.LanguageServer.Server do if is_nil(prev_target) or target == prev_target do Mix.target(String.to_atom(target)) else - JsonRpc.show_message(:warning, "You must restart ElixirLS after changing Mix target") + JsonRpc.show_message(:warning, "Mix target change detected. ElixirLS will restart") + + Process.sleep(5000) + System.halt(1) end state @@ -1202,7 +1254,7 @@ defmodule ElixirLS.LanguageServer.Server do project_dir ) when is_binary(root_uri) do - root_dir = root_uri |> SourceFile.abs_path_from_uri() + root_dir = SourceFile.Path.absolute_from_uri(root_uri) project_dir = if is_binary(project_dir) do @@ -1218,15 +1270,16 @@ defmodule ElixirLS.LanguageServer.Server do is_nil(prev_project_dir) -> File.cd!(project_dir) - Map.merge(state, %{project_dir: File.cwd!(), load_all_modules?: true}) + %{state | project_dir: File.cwd!(), mix_project?: File.exists?(MixfileHelpers.mix_exs())} prev_project_dir != project_dir -> JsonRpc.show_message( :warning, - "You must restart ElixirLS after changing the project directory" + "Project directory change detected. ElixirLS will restart" ) - state + Process.sleep(5000) + System.halt(1) true -> state @@ -1249,10 +1302,7 @@ defmodule ElixirLS.LanguageServer.Server do state {:error, err} -> - JsonRpc.log_message( - :warning, - "Cannot create .elixir_ls/.gitignore, cause: #{Atom.to_string(err)}" - ) + Logger.warning("Cannot create .elixir_ls/.gitignore, cause: #{Atom.to_string(err)}") state end @@ -1278,4 +1328,22 @@ defmodule ElixirLS.LanguageServer.Server do _ -> false end) end + + defp maybe_rebuild(state = %__MODULE__{project_dir: project_dir}) do + # detect if we are opening a project that has been compiled without a tracer + if is_binary(project_dir) and state.mix_project? and + File.dir?(Path.join([project_dir, ".elixir_ls"])) and + not Tracer.manifest_version_current?(project_dir) do + Logger.info("DETS databases will be rebuilt") + Tracer.clean_dets(project_dir) + + case Build.reload_project() do + {:ok, _} -> + Build.clean(true) + + _ -> + :ok + end + end + end end diff --git a/apps/language_server/lib/language_server/source_file.ex b/apps/language_server/lib/language_server/source_file.ex index 5d3782623..5e455b98a 100644 --- a/apps/language_server/lib/language_server/source_file.ex +++ b/apps/language_server/lib/language_server/source_file.ex @@ -1,5 +1,6 @@ defmodule ElixirLS.LanguageServer.SourceFile do import ElixirLS.LanguageServer.Protocol + require Logger defstruct [:text, :version, dirty?: false] @@ -62,108 +63,6 @@ defmodule ElixirLS.LanguageServer.SourceFile do apply_content_changes(source_file, rest) end - @doc """ - Returns path from URI in a way that handles windows file:///c%3A/... URLs correctly - """ - def path_from_uri(%URI{scheme: "file", path: path, authority: authority}) do - uri_path = - cond do - path == nil -> - # treat no path as root path - "/" - - authority not in ["", nil] and path not in ["", nil] -> - # UNC path - "//#{URI.decode(authority)}#{URI.decode(path)}" - - true -> - decoded_path = URI.decode(path) - - if match?({:win32, _}, :os.type()) and - String.match?(decoded_path, ~r/^\/[a-zA-Z]:/) do - # Windows drive letter path - # drop leading `/` and downcase drive letter - <<_, letter, path_rest::binary>> = decoded_path - <> - else - decoded_path - end - end - - case :os.type() do - {:win32, _} -> - # convert path separators from URI to Windows - String.replace(uri_path, ~r/\//, "\\") - - _ -> - uri_path - end - end - - def path_from_uri(%URI{scheme: scheme}) do - raise ArgumentError, message: "unexpected URI scheme #{inspect(scheme)}" - end - - def path_from_uri(uri) do - uri |> URI.parse() |> path_from_uri - end - - def path_to_uri(path) do - path = Path.expand(path) - - path = - case :os.type() do - {:win32, _} -> - # convert path separators from Windows to URI - String.replace(path, ~r/\\/, "/") - - _ -> - path - end - - {authority, path} = - case path do - "//" <> rest -> - # UNC path - extract authority - case String.split(rest, "/", parts: 2) do - [_] -> - # no path part, use root path - {rest, "/"} - - [a, ""] -> - # empty path part, use root path - {a, "/"} - - [a, p] -> - {a, "/" <> p} - end - - "/" <> _rest -> - {"", path} - - other -> - # treat as relative to root path - {"", "/" <> other} - end - - %URI{ - scheme: "file", - authority: authority |> URI.encode(), - # file system paths allow reserved URI characters that need to be escaped - # the exact rules are complicated but for simplicity we escape all reserved except `/` - # that's what https://github.com/microsoft/vscode-uri does - path: path |> URI.encode(&(&1 == ?/ or URI.char_unreserved?(&1))) - } - |> URI.to_string() - end - - defp downcase(char) when char >= ?A and char <= ?Z, do: char + 32 - defp downcase(char), do: char - - def abs_path_from_uri(uri) do - uri |> path_from_uri |> Path.absname() - end - def full_range(source_file) do lines = lines(source_file) @@ -172,10 +71,7 @@ defmodule ElixirLS.LanguageServer.SourceFile do |> List.last() |> line_length_utf16() - %{ - "start" => %{"line" => 0, "character" => 0}, - "end" => %{"line" => Enum.count(lines) - 1, "character" => utf16_size} - } + range(0, 0, Enum.count(lines) - 1, utf16_size) end def line_length_utf16(line) do @@ -337,27 +233,31 @@ defmodule ElixirLS.LanguageServer.SourceFile do """ end - @spec formatter_opts(String.t()) :: {:ok, keyword()} | :error - def formatter_opts(uri = "file:" <> _) do - path = path_from_uri(uri) + @spec formatter_for(String.t()) :: {:ok, {function | nil, keyword()}} | :error + def formatter_for(uri = "file:" <> _) do + path = __MODULE__.Path.from_uri(uri) try do - opts = - path - |> Mix.Tasks.Format.formatter_opts_for_file() + true = Code.ensure_loaded?(Mix.Tasks.Format) - {:ok, opts} + if Version.match?(System.version(), ">= 1.13.0") do + {:ok, apply(Mix.Tasks.Format, :formatter_for_file, [path])} + else + {:ok, {nil, apply(Mix.Tasks.Format, :formatter_opts_for_file, [path])}} + end rescue e -> - IO.warn( - "Unable to get formatter options for #{path}: #{inspect(e.__struct__)} #{e.message}" + message = Exception.message(e) + + Logger.warn( + "Unable to get formatter options for #{path}: #{inspect(e.__struct__)} #{message}" ) :error end end - def formatter_opts(_), do: :error + def formatter_for(_), do: :error defp format_code(code, opts) do try do @@ -373,4 +273,72 @@ defmodule ElixirLS.LanguageServer.SourceFile do end defp remove_indentation(lines, _), do: lines + + def lsp_character_to_elixir(_utf8_line, lsp_character) when lsp_character <= 0, do: 1 + + def lsp_character_to_elixir(utf8_line, lsp_character) do + utf16_line = + utf8_line + |> characters_to_binary!(:utf8, :utf16) + + byte_size = byte_size(utf16_line) + + utf8_character = + utf16_line + |> (&binary_part( + &1, + 0, + min(lsp_character * 2, byte_size) + )).() + |> characters_to_binary!(:utf16, :utf8) + |> String.length() + + utf8_character + 1 + end + + def lsp_position_to_elixir(_urf8_text, {lsp_line, _lsp_character}) when lsp_line < 0, + do: {1, 1} + + def lsp_position_to_elixir(_urf8_text, {lsp_line, lsp_character}) when lsp_character <= 0, + do: {max(lsp_line + 1, 1), 1} + + def lsp_position_to_elixir(urf8_text, {lsp_line, lsp_character}) do + source_file_lines = lines(urf8_text) + total_lines = length(source_file_lines) + + if lsp_line > total_lines - 1 do + # sanitize to position after last char in last line + {total_lines, String.length(source_file_lines |> Enum.at(total_lines - 1)) + 1} + else + utf8_character = + source_file_lines + |> Enum.at(lsp_line) + |> lsp_character_to_elixir(lsp_character) + + {lsp_line + 1, utf8_character} + end + end + + def elixir_character_to_lsp(_utf8_line, elixir_character) when elixir_character <= 1, do: 0 + + def elixir_character_to_lsp(utf8_line, elixir_character) do + utf8_line + |> String.slice(0..(elixir_character - 2)) + |> characters_to_binary!(:utf8, :utf16) + |> byte_size() + |> div(2) + end + + def elixir_position_to_lsp(_urf8_text, {elixir_line, elixir_character}) + when elixir_character <= 1, + do: {max(elixir_line - 1, 0), 0} + + def elixir_position_to_lsp(urf8_text, {elixir_line, elixir_character}) do + utf16_character = + lines(urf8_text) + |> Enum.at(max(elixir_line - 1, 0)) + |> elixir_character_to_lsp(elixir_character) + + {elixir_line - 1, utf16_character} + end end diff --git a/apps/language_server/lib/language_server/source_file/path.ex b/apps/language_server/lib/language_server/source_file/path.ex new file mode 100644 index 000000000..31ffddddc --- /dev/null +++ b/apps/language_server/lib/language_server/source_file/path.ex @@ -0,0 +1,115 @@ +defmodule ElixirLS.LanguageServer.SourceFile.Path do + @file_scheme "file" + + @doc """ + Returns path from URI in a way that handles windows file:///c%3A/... URLs correctly + """ + def from_uri(%URI{scheme: @file_scheme, path: nil}) do + # treat no path as root path + convert_separators_to_native("/") + end + + def from_uri(%URI{scheme: @file_scheme, path: path, authority: authority}) + when path != "" and authority not in ["", nil] do + # UNC path + convert_separators_to_native("//#{URI.decode(authority)}#{URI.decode(path)}") + end + + def from_uri(%URI{scheme: @file_scheme, path: path}) do + decoded_path = URI.decode(path) + + if windows?() and String.match?(decoded_path, ~r/^\/[a-zA-Z]:/) do + # Windows drive letter path + # drop leading `/` and downcase drive letter + <<"/", letter::binary-size(1), path_rest::binary>> = decoded_path + "#{String.downcase(letter)}#{path_rest}" + else + decoded_path + end + |> convert_separators_to_native() + end + + def from_uri(%URI{scheme: scheme}) do + raise ArgumentError, message: "unexpected URI scheme #{inspect(scheme)}" + end + + def from_uri(uri) do + uri |> URI.parse() |> from_uri() + end + + def absolute_from_uri(uri) do + uri |> from_uri |> Path.absname() + end + + def to_uri(path) do + path = + path + |> Path.expand() + |> convert_separators_to_universal() + + {authority, path} = + case path do + "//" <> rest -> + # UNC path - extract authority + case String.split(rest, "/", parts: 2) do + [_] -> + # no path part, use root path + {rest, "/"} + + [authority, ""] -> + # empty path part, use root path + {authority, "/"} + + [authority, p] -> + {authority, "/" <> p} + end + + "/" <> _rest -> + {"", path} + + other -> + # treat as relative to root path + {"", "/" <> other} + end + + %URI{ + scheme: @file_scheme, + authority: authority |> URI.encode(), + # file system paths allow reserved URI characters that need to be escaped + # the exact rules are complicated but for simplicity we escape all reserved except `/` + # that's what https://github.com/microsoft/vscode-uri does + path: path |> URI.encode(&(&1 == ?/ or URI.char_unreserved?(&1))) + } + |> URI.to_string() + end + + defp convert_separators_to_native(path) do + if windows?() do + # convert path separators from URI to Windows + String.replace(path, ~r/\//, "\\") + else + path + end + end + + defp convert_separators_to_universal(path) do + if windows?() do + # convert path separators from Windows to URI + String.replace(path, ~r/\\/, "/") + else + path + end + end + + defp windows? do + case os_type() do + {:win32, _} -> true + _ -> false + end + end + + # this is here to be mocked in tests + defp os_type do + :os.type() + end +end diff --git a/apps/language_server/lib/language_server/tracer.ex b/apps/language_server/lib/language_server/tracer.ex new file mode 100644 index 000000000..81f362467 --- /dev/null +++ b/apps/language_server/lib/language_server/tracer.ex @@ -0,0 +1,380 @@ +defmodule ElixirLS.LanguageServer.Tracer do + @moduledoc """ + """ + use GenServer + require Logger + + @version 1 + + @tables ~w(modules calls)a + + for table <- @tables do + defp table_name(unquote(table)) do + :"#{__MODULE__}:#{unquote(table)}" + end + end + + def start_link(args) do + GenServer.start_link(__MODULE__, args, name: __MODULE__) + end + + def set_project_dir(project_dir) do + GenServer.call(__MODULE__, {:set_project_dir, project_dir}) + end + + def save() do + GenServer.cast(__MODULE__, :save) + end + + defp get_project_dir() do + case Process.get(:elixir_ls_project_dir) do + nil -> + project_dir = GenServer.call(__MODULE__, :get_project_dir) + Process.put(:elixir_ls_project_dir, project_dir) + project_dir + + project_dir -> + project_dir + end + end + + def notify_file_deleted(file) do + delete_modules_by_file(file) + delete_calls_by_file(file) + end + + @impl true + def init(_args) do + for table <- @tables do + table_name = table_name(table) + + :ets.new(table_name, [ + :named_table, + :public, + read_concurrency: true, + write_concurrency: true + ]) + end + + {:ok, %{project_dir: nil}} + end + + @impl true + def handle_call({:set_project_dir, project_dir}, _from, state) do + maybe_close_tables(state) + + for table <- @tables do + table_name = table_name(table) + :ets.delete_all_objects(table_name) + end + + if project_dir != nil do + {us, _} = + :timer.tc(fn -> + for table <- @tables do + init_table(table, project_dir) + end + end) + + Logger.info("Loaded DETS databases in #{div(us, 1000)}ms") + end + + {:reply, :ok, %{state | project_dir: project_dir}} + end + + def handle_call(:get_project_dir, _from, %{project_dir: project_dir} = state) do + {:reply, project_dir, state} + end + + @impl true + def handle_cast(:save, %{project_dir: project_dir} = state) do + for table <- @tables do + table_name = table_name(table) + + sync(table_name) + end + + write_manifest(project_dir) + + {:noreply, state} + end + + @impl true + def terminate(_reason, state) do + maybe_close_tables(state) + end + + defp maybe_close_tables(%{project_dir: nil}), do: :ok + + defp maybe_close_tables(%{project_dir: project_dir}) do + for table <- @tables do + close_table(table, project_dir) + end + + :ok + end + + defp dets_path(project_dir, table) do + Path.join([project_dir, ".elixir_ls", "#{table}.dets"]) + end + + def init_table(table, project_dir) do + table_name = table_name(table) + path = dets_path(project_dir, table) + + {:ok, _} = + :dets.open_file(table_name, + file: path |> String.to_charlist(), + auto_save: 60_000 + ) + + case :dets.to_ets(table_name, table_name) do + ^table_name -> + :ok + + {:error, reason} -> + Logger.error("Unable to load DETS #{path}, #{inspect(reason)}") + end + end + + def close_table(table, project_dir) do + path = dets_path(project_dir, table) + table_name = table_name(table) + sync(table_name) + + case :dets.close(table_name) do + :ok -> + :ok + + {:error, reason} -> + Logger.error("Unable to close DETS #{path}, #{inspect(reason)}") + end + end + + defp modules_by_file_matchspec(file, return) do + [ + {{:"$1", :"$2"}, + [ + { + :andalso, + {:andalso, {:==, {:map_get, :file, :"$2"}, file}} + } + ], [return]} + ] + end + + def get_modules_by_file(file) do + ms = modules_by_file_matchspec(file, :"$_") + # ms = :ets.fun2ms(fn {_, map} when :erlang.map_get(:file, map) == file -> map end) + + :ets.select(table_name(:modules), ms) + end + + def delete_modules_by_file(file) do + ms = modules_by_file_matchspec(file, true) + # ms = :ets.fun2ms(fn {_, map} when :erlang.map_get(:file, map) == file -> true end) + + :ets.select_delete(table_name(:modules), ms) + end + + def trace(:start, %Macro.Env{} = env) do + delete_modules_by_file(env.file) + delete_calls_by_file(env.file) + :ok + end + + def trace({:on_module, _, _}, %Macro.Env{} = env) do + info = build_module_info(env.module, env.file, env.line) + :ets.insert(table_name(:modules), {env.module, info}) + :ok + end + + def trace({kind, meta, module, name, arity}, %Macro.Env{} = env) + when kind in [:imported_function, :imported_macro, :remote_function, :remote_macro] do + register_call(meta, module, name, arity, env) + end + + def trace({kind, meta, name, arity}, %Macro.Env{} = env) + when kind in [:local_function, :local_macro] do + register_call(meta, env.module, name, arity, env) + end + + def trace(_trace, _env) do + # IO.inspect(trace, label: "skipped") + :ok + end + + defp build_module_info(module, file, line) do + defs = + for {name, arity} <- Module.definitions_in(module) do + def_info = apply(Module, :get_definition, [module, {name, arity}]) + {{name, arity}, build_def_info(def_info)} + end + + attributes = + if Version.match?(System.version(), ">= 1.13.0") do + for name <- apply(Module, :attributes_in, [module]) do + {name, Module.get_attribute(module, name)} + end + else + [] + end + + %{ + defs: defs, + attributes: attributes, + file: file, + line: line + } + end + + defp build_def_info({:v1, def_kind, meta_1, clauses}) do + clauses = + for {meta_2, arguments, guards, _body} <- clauses do + %{ + arguments: arguments, + guards: guards, + meta: meta_2 + } + end + + %{ + kind: def_kind, + clauses: clauses, + meta: meta_1 + } + end + + defp register_call(meta, module, name, arity, env) do + if in_project_sources?(env.file) do + do_register_call(meta, module, name, arity, env) + end + + :ok + end + + defp do_register_call(meta, module, name, arity, env) do + callee = {module, name, arity} + + call = %{ + callee: callee, + file: env.file, + line: meta[:line], + column: meta[:column] + } + + updated_calls = + case :ets.lookup(table_name(:calls), callee) do + [{_callee, callee_calls}] when is_map(callee_calls) -> + file_calle_calls = + case callee_calls[env.file] do + nil -> [call] + file_calle_calls -> [call | file_calle_calls] + end + + Map.put(callee_calls, env.file, file_calle_calls) + + [] -> + %{env.file => [call]} + end + + :ets.insert(table_name(:calls), {callee, updated_calls}) + end + + def get_trace do + :ets.tab2list(table_name(:calls)) + |> Map.new(fn {callee, calls_by_file} -> + calls = calls_by_file |> Map.values() |> List.flatten() + {callee, calls} + end) + end + + defp sync(table_name) do + with :ok <- :dets.from_ets(table_name, table_name), + :ok <- :dets.sync(table_name) do + :ok + else + {:error, reason} -> + Logger.error("Unable to sync DETS #{table_name}, #{inspect(reason)}") + end + end + + defp in_project_sources?(path) do + project_dir = get_project_dir() + + if project_dir != nil do + topmost_path_segment = + path + |> Path.relative_to(project_dir) + |> Path.split() + |> hd + + topmost_path_segment != "deps" + else + false + end + end + + defp calls_by_file_matchspec(file, return) do + [ + {{:"$1", :"$2"}, + [ + { + :andalso, + {:andalso, {:is_list, {:map_get, file, :"$2"}}} + } + ], [return]} + ] + end + + def get_calls_by_file(file) do + ms = calls_by_file_matchspec(file, :"$_") + + :ets.select(table_name(:calls), ms) + end + + def delete_calls_by_file(file) do + ms = calls_by_file_matchspec(file, :"$_") + table_name = table_name(:calls) + + for {callee, calls_by_file} <- :ets.select(table_name(:calls), ms) do + calls_by_file = calls_by_file |> Map.delete(file) + + if calls_by_file == %{} do + :ets.delete(table_name, callee) + else + :ets.insert(table_name, {callee, calls_by_file}) + end + end + end + + defp manifest_path(project_dir) do + Path.join([project_dir, ".elixir_ls", "tracer_db.manifest"]) + end + + def write_manifest(project_dir) do + path = manifest_path(project_dir) + File.rm_rf!(path) + File.write!(path, "#{@version}") + end + + def read_manifest(project_dir) do + with {:ok, text} <- File.read(manifest_path(project_dir)), + {version, ""} <- Integer.parse(text) do + version + else + _ -> nil + end + end + + def manifest_version_current?(project_dir) do + read_manifest(project_dir) == @version + end + + def clean_dets(project_dir) do + for path <- + Path.join([project_dir, ".elixir_ls/*.dets"]) + |> Path.wildcard(), + do: File.rm_rf!(path) + end +end diff --git a/apps/language_server/mix.exs b/apps/language_server/mix.exs index 91db24f15..386f6b967 100644 --- a/apps/language_server/mix.exs +++ b/apps/language_server/mix.exs @@ -4,8 +4,8 @@ defmodule ElixirLS.LanguageServer.Mixfile do def project do [ app: :language_server, - version: "0.9.0", - elixir: ">= 1.10.0", + version: "0.12.0", + elixir: ">= 1.12.3", build_path: "../../_build", config_path: "../../config/config.exs", deps_path: "../../deps", @@ -29,10 +29,11 @@ defmodule ElixirLS.LanguageServer.Mixfile do {:elixir_ls_utils, in_umbrella: true}, {:elixir_sense, github: "elixir-lsp/elixir_sense"}, {:erl2ex, github: "dazuma/erl2ex"}, - {:dialyxir, "~> 1.0", runtime: false}, + {:dialyxir_vendored, github: "elixir-lsp/dialyxir", branch: "vendored", runtime: false}, {:jason_vendored, github: "elixir-lsp/jason", branch: "vendored"}, {:stream_data, "~> 0.5", only: :test}, - {:path_glob_vendored, github: "elixir-lsp/path_glob", branch: "vendored"} + {:path_glob_vendored, github: "elixir-lsp/path_glob", branch: "vendored"}, + {:patch, "~> 0.12.0", only: :test} ] end diff --git a/apps/language_server/test/diagnostics_test.exs b/apps/language_server/test/diagnostics_test.exs index d1f99bdec..999258a07 100644 --- a/apps/language_server/test/diagnostics_test.exs +++ b/apps/language_server/test/diagnostics_test.exs @@ -61,6 +61,30 @@ defmodule ElixirLS.LanguageServer.DiagnosticsTest do assert diagnostic.position == 3 end + test "update file and position with column if file is present in the message" do + root_path = Path.join(__DIR__, "fixtures/build_errors") + file = Path.join(root_path, "lib/has_error.ex") + position = 2 + + message = """ + ** (CompileError) lib/has_error.ex:3:5: some message + lib/my_app/my_module.ex:10: MyApp.MyModule.render/1 + """ + + [diagnostic | _] = + [build_diagnostic(message, file, position)] + |> Diagnostics.normalize(root_path) + + assert diagnostic.message == """ + (CompileError) some message + + Stacktrace: + │ lib/my_app/my_module.ex:10: MyApp.MyModule.render/1\ + """ + + assert diagnostic.position == {3, 5} + end + test "update file and position if file is present in the message (umbrella)" do root_path = Path.join(__DIR__, "fixtures/umbrella") file = Path.join(root_path, "lib/file_to_be_replaced.ex") diff --git a/apps/language_server/test/dialyzer_test.exs b/apps/language_server/test/dialyzer_test.exs index 082a3f06b..58ce14d94 100644 --- a/apps/language_server/test/dialyzer_test.exs +++ b/apps/language_server/test/dialyzer_test.exs @@ -1,7 +1,7 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do # TODO: Test loading and saving manifest - alias ElixirLS.LanguageServer.{Dialyzer, Server, Protocol, SourceFile, JsonRpc} + alias ElixirLS.LanguageServer.{Dialyzer, Server, Protocol, SourceFile, JsonRpc, Tracer, Build} import ExUnit.CaptureLog use ElixirLS.Utils.MixTest.Case, async: false use Protocol @@ -10,10 +10,18 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do # This will generate a large PLT file and will take a long time, so we need to make sure that # Mix.Utils.home() is in the saved build artifacts for any automated testing Dialyzer.Manifest.load_elixir_plt() + compiler_options = Code.compiler_options() + Build.set_compiler_options() + + on_exit(fn -> + Code.compiler_options(compiler_options) + end) + {:ok, %{}} end setup do + {:ok, _} = Tracer.start_link([]) server = ElixirLS.LanguageServer.Test.ServerTestHelpers.start_server() {:ok, %{server: server}} @@ -22,10 +30,10 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do @tag slow: true, fixture: true test "reports diagnostics then clears them once problems are fixed", %{server: server} do in_fixture(__DIR__, "dialyzer", fn -> - file_a = SourceFile.path_to_uri(Path.absname("lib/a.ex")) + file_a = SourceFile.Path.to_uri(Path.absname("lib/a.ex")) capture_log(fn -> - root_uri = SourceFile.path_to_uri(File.cwd!()) + root_uri = SourceFile.Path.to_uri(File.cwd!()) Server.receive_packet(server, initialize_req(1, root_uri, %{})) Server.receive_packet( @@ -41,8 +49,8 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do %{ "message" => error_message1, "range" => %{ - "end" => %{"character" => 0, "line" => _}, - "start" => %{"character" => 0, "line" => _} + "end" => %{"character" => 12, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} }, "severity" => 2, "source" => "ElixirLS Dialyzer" @@ -50,8 +58,8 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do %{ "message" => error_message2, "range" => %{ - "end" => %{"character" => 0, "line" => _}, - "start" => %{"character" => 0, "line" => _} + "end" => %{"character" => 17, "line" => 2}, + "start" => %{"character" => 4, "line" => 2} }, "severity" => 2, "source" => "ElixirLS Dialyzer" @@ -72,7 +80,7 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do end """ - b_uri = SourceFile.path_to_uri("lib/b.ex") + b_uri = SourceFile.Path.to_uri("lib/b.ex") Server.receive_packet(server, did_open(b_uri, "elixir", 1, b_text)) Process.sleep(1500) File.write!("lib/b.ex", b_text) @@ -95,10 +103,10 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do @tag slow: true, fixture: true test "only analyzes the changed files", %{server: server} do in_fixture(__DIR__, "dialyzer", fn -> - file_c = SourceFile.path_to_uri(Path.absname("lib/c.ex")) + file_c = SourceFile.Path.to_uri(Path.absname("lib/c.ex")) capture_log(fn -> - root_uri = SourceFile.path_to_uri(File.cwd!()) + root_uri = SourceFile.Path.to_uri(File.cwd!()) Server.receive_packet(server, initialize_req(1, root_uri, %{})) Server.receive_packet( @@ -115,7 +123,7 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do end """ - c_uri = SourceFile.path_to_uri("lib/c.ex") + c_uri = SourceFile.Path.to_uri("lib/c.ex") assert_receive notification("window/logMessage", %{ "message" => "[ElixirLS Dialyzer] Found " <> _ @@ -156,10 +164,10 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do @tag slow: true, fixture: true test "reports dialyxir_long formatted error", %{server: server} do in_fixture(__DIR__, "dialyzer", fn -> - file_a = SourceFile.path_to_uri(Path.absname("lib/a.ex")) + file_a = SourceFile.Path.to_uri(Path.absname("lib/a.ex")) capture_log(fn -> - root_uri = SourceFile.path_to_uri(File.cwd!()) + root_uri = SourceFile.Path.to_uri(File.cwd!()) Server.receive_packet(server, initialize_req(1, root_uri, %{})) Server.receive_packet( @@ -175,8 +183,8 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do %{ "message" => error_message1, "range" => %{ - "end" => %{"character" => 0, "line" => _}, - "start" => %{"character" => 0, "line" => _} + "end" => %{"character" => 12, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} }, "severity" => 2, "source" => "ElixirLS Dialyzer" @@ -184,8 +192,8 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do %{ "message" => error_message2, "range" => %{ - "end" => %{"character" => 0, "line" => _}, - "start" => %{"character" => 0, "line" => _} + "end" => %{"character" => 17, "line" => 2}, + "start" => %{"character" => 4, "line" => 2} }, "severity" => 2, "source" => "ElixirLS Dialyzer" @@ -210,10 +218,10 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do @tag slow: true, fixture: true test "reports dialyxir_short formatted error", %{server: server} do in_fixture(__DIR__, "dialyzer", fn -> - file_a = SourceFile.path_to_uri(Path.absname("lib/a.ex")) + file_a = SourceFile.Path.to_uri(Path.absname("lib/a.ex")) capture_log(fn -> - root_uri = SourceFile.path_to_uri(File.cwd!()) + root_uri = SourceFile.Path.to_uri(File.cwd!()) Server.receive_packet(server, initialize_req(1, root_uri, %{})) Server.receive_packet( @@ -229,8 +237,8 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do %{ "message" => error_message1, "range" => %{ - "end" => %{"character" => 0, "line" => _}, - "start" => %{"character" => 0, "line" => _} + "end" => %{"character" => 12, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} }, "severity" => 2, "source" => "ElixirLS Dialyzer" @@ -238,8 +246,8 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do %{ "message" => error_message2, "range" => %{ - "end" => %{"character" => 0, "line" => _}, - "start" => %{"character" => 0, "line" => _} + "end" => %{"character" => 17, "line" => 2}, + "start" => %{"character" => 4, "line" => 2} }, "severity" => 2, "source" => "ElixirLS Dialyzer" @@ -255,10 +263,10 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do @tag slow: true, fixture: true test "reports dialyzer_formatted error", %{server: server} do in_fixture(__DIR__, "dialyzer", fn -> - file_a = SourceFile.path_to_uri(Path.absname("lib/a.ex")) + file_a = SourceFile.Path.to_uri(Path.absname("lib/a.ex")) capture_log(fn -> - root_uri = SourceFile.path_to_uri(File.cwd!()) + root_uri = SourceFile.Path.to_uri(File.cwd!()) Server.receive_packet(server, initialize_req(1, root_uri, %{})) Server.receive_packet( @@ -274,8 +282,8 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do %{ "message" => error_message1, "range" => %{ - "end" => %{"character" => 0, "line" => _}, - "start" => %{"character" => 0, "line" => _} + "end" => %{"character" => 12, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} }, "severity" => 2, "source" => "ElixirLS Dialyzer" @@ -283,8 +291,8 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do %{ "message" => _error_message2, "range" => %{ - "end" => %{"character" => 0, "line" => _}, - "start" => %{"character" => 0, "line" => _} + "end" => %{"character" => 17, "line" => 2}, + "start" => %{"character" => 4, "line" => 2} }, "severity" => 2, "source" => "ElixirLS Dialyzer" @@ -301,10 +309,10 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do @tag slow: true, fixture: true test "reports dialyxir_short error in umbrella", %{server: server} do in_fixture(__DIR__, "umbrella_dialyzer", fn -> - file_a = SourceFile.path_to_uri(Path.absname("apps/app1/lib/app1.ex")) + file_a = SourceFile.Path.to_uri(Path.absname("apps/app1/lib/app1.ex")) capture_log(fn -> - root_uri = SourceFile.path_to_uri(File.cwd!()) + root_uri = SourceFile.Path.to_uri(File.cwd!()) Server.receive_packet(server, initialize_req(1, root_uri, %{})) Server.receive_packet( @@ -320,8 +328,8 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do %{ "message" => error_message1, "range" => %{ - "end" => %{"character" => 0, "line" => _}, - "start" => %{"character" => 0, "line" => _} + "end" => %{"character" => 22, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} }, "severity" => 2, "source" => "ElixirLS Dialyzer" @@ -329,8 +337,8 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do %{ "message" => error_message2, "range" => %{ - "end" => %{"character" => 0, "line" => _}, - "start" => %{"character" => 0, "line" => _} + "end" => %{"character" => 22, "line" => 2}, + "start" => %{"character" => 4, "line" => 2} }, "severity" => 2, "source" => "ElixirLS Dialyzer" @@ -345,10 +353,10 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do test "clears diagnostics when source files are deleted", %{server: server} do in_fixture(__DIR__, "dialyzer", fn -> - file_a = SourceFile.path_to_uri(Path.absname("lib/a.ex")) + file_a = SourceFile.Path.to_uri(Path.absname("lib/a.ex")) capture_log(fn -> - root_uri = SourceFile.path_to_uri(File.cwd!()) + root_uri = SourceFile.Path.to_uri(File.cwd!()) Server.receive_packet(server, initialize_req(1, root_uri, %{})) Server.receive_packet( @@ -372,8 +380,8 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do @tag slow: true, fixture: true test "protocol rebuild does not trigger consolidation warnings", %{server: server} do in_fixture(__DIR__, "protocols", fn -> - root_uri = SourceFile.path_to_uri(File.cwd!()) - uri = SourceFile.path_to_uri(Path.absname("lib/implementations.ex")) + root_uri = SourceFile.Path.to_uri(File.cwd!()) + uri = SourceFile.Path.to_uri(Path.absname("lib/implementations.ex")) Server.receive_packet(server, initialize_req(1, root_uri, %{})) Server.receive_packet(server, notification("initialized")) @@ -446,10 +454,10 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do @tag slow: true, fixture: true test "do not suggests contracts if not enabled", %{server: server} do in_fixture(__DIR__, "dialyzer", fn -> - file_c = SourceFile.path_to_uri(Path.absname("lib/c.ex")) + file_c = SourceFile.Path.to_uri(Path.absname("lib/c.ex")) capture_log(fn -> - root_uri = SourceFile.path_to_uri(File.cwd!()) + root_uri = SourceFile.Path.to_uri(File.cwd!()) Server.receive_packet(server, initialize_req(1, root_uri, %{})) Server.receive_packet( @@ -483,10 +491,10 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do @tag slow: true, fixture: true test "suggests contracts if enabled and applies suggestion", %{server: server} do in_fixture(__DIR__, "dialyzer", fn -> - file_c = SourceFile.path_to_uri(Path.absname("lib/c.ex")) + file_c = SourceFile.Path.to_uri(Path.absname("lib/c.ex")) capture_log(fn -> - root_uri = SourceFile.path_to_uri(File.cwd!()) + root_uri = SourceFile.Path.to_uri(File.cwd!()) Server.receive_packet(server, initialize_req(1, root_uri, %{})) Server.receive_packet( diff --git a/apps/language_server/test/fixtures/clean/lib/a.ex b/apps/language_server/test/fixtures/clean/lib/a.ex new file mode 100644 index 000000000..ec7cb179f --- /dev/null +++ b/apps/language_server/test/fixtures/clean/lib/a.ex @@ -0,0 +1,5 @@ +defmodule A do + def fun do + :ok = B.fun() + end +end diff --git a/apps/language_server/test/fixtures/clean/lib/b.ex b/apps/language_server/test/fixtures/clean/lib/b.ex new file mode 100644 index 000000000..b7ce208dc --- /dev/null +++ b/apps/language_server/test/fixtures/clean/lib/b.ex @@ -0,0 +1,5 @@ +defmodule B do + def fun do + :error + end +end diff --git a/apps/language_server/test/fixtures/clean/lib/c.ex b/apps/language_server/test/fixtures/clean/lib/c.ex new file mode 100644 index 000000000..3cce833b8 --- /dev/null +++ b/apps/language_server/test/fixtures/clean/lib/c.ex @@ -0,0 +1,5 @@ +defmodule C do + def myfun do + 1 + end +end diff --git a/apps/language_server/test/fixtures/clean/mix.exs b/apps/language_server/test/fixtures/clean/mix.exs new file mode 100644 index 000000000..c8b5fc4dc --- /dev/null +++ b/apps/language_server/test/fixtures/clean/mix.exs @@ -0,0 +1,15 @@ +defmodule ElixirLS.LanguageServer.Fixtures.Clean.Mixfile do + use Mix.Project + + def project do + [app: :els_clean_test, version: "0.1.0"] + end + + # Configuration for the OTP application + # + # Type "mix help compile.app" for more information + def application do + # Specify extra applications you'll use from Erlang/Elixir + [] + end +end diff --git a/apps/language_server/test/fixtures/umbrella_test_code_lens/apps/app/mix.exs b/apps/language_server/test/fixtures/umbrella_test_code_lens/apps/app/mix.exs new file mode 100644 index 000000000..2cce26bb7 --- /dev/null +++ b/apps/language_server/test/fixtures/umbrella_test_code_lens/apps/app/mix.exs @@ -0,0 +1,14 @@ +defmodule App.Mixfile do + use Mix.Project + + def project do + [ + app: :app, + version: "0.1.0" + ] + end + + def application do + [] + end +end diff --git a/apps/language_server/test/fixtures/umbrella_test_code_lens/apps/app/test/fixture_custom_test.exs b/apps/language_server/test/fixtures/umbrella_test_code_lens/apps/app/test/fixture_custom_test.exs new file mode 100644 index 000000000..bf4c51fb2 --- /dev/null +++ b/apps/language_server/test/fixtures/umbrella_test_code_lens/apps/app/test/fixture_custom_test.exs @@ -0,0 +1,7 @@ +defmodule App.UmbrellaTestCodeLensTest do + use ExUnit.Case + + test "fixture test" do + assert true + end +end diff --git a/apps/language_server/test/fixtures/umbrella_test_code_lens/apps/app1/mix.exs b/apps/language_server/test/fixtures/umbrella_test_code_lens/apps/app1/mix.exs new file mode 100644 index 000000000..7bcab07a9 --- /dev/null +++ b/apps/language_server/test/fixtures/umbrella_test_code_lens/apps/app1/mix.exs @@ -0,0 +1,14 @@ +defmodule App1.Mixfile do + use Mix.Project + + def project do + [ + app: :app1, + version: "0.1.0" + ] + end + + def application do + [] + end +end diff --git a/apps/language_server/test/fixtures/umbrella_test_code_lens/apps/app1/test/fixture_custom_test.exs b/apps/language_server/test/fixtures/umbrella_test_code_lens/apps/app1/test/fixture_custom_test.exs new file mode 100644 index 000000000..5b1137b0f --- /dev/null +++ b/apps/language_server/test/fixtures/umbrella_test_code_lens/apps/app1/test/fixture_custom_test.exs @@ -0,0 +1,7 @@ +defmodule App1.UmbrellaTestCodeLensTest do + use ExUnit.Case + + test "fixture test" do + assert true + end +end diff --git a/apps/language_server/test/fixtures/umbrella_test_code_lens/mix.exs b/apps/language_server/test/fixtures/umbrella_test_code_lens/mix.exs new file mode 100644 index 000000000..03459a9e8 --- /dev/null +++ b/apps/language_server/test/fixtures/umbrella_test_code_lens/mix.exs @@ -0,0 +1,7 @@ +defmodule UmbrellaTestCodeLens.Mixfile do + use Mix.Project + + def project do + [apps_path: "apps"] + end +end diff --git a/apps/language_server/test/providers/code_lens/test_test.exs b/apps/language_server/test/providers/code_lens/test_test.exs index 6912dae40..ad7f56136 100644 --- a/apps/language_server/test/providers/code_lens/test_test.exs +++ b/apps/language_server/test/providers/code_lens/test_test.exs @@ -7,8 +7,6 @@ defmodule ElixirLS.LanguageServer.Providers.CodeLens.TestTest do @project_dir "/project" setup context do - ElixirLS.LanguageServer.Build.load_all_modules() - unless context[:skip_server] do server = ElixirLS.LanguageServer.Test.ServerTestHelpers.start_server() diff --git a/apps/language_server/test/providers/completion_test.exs b/apps/language_server/test/providers/completion_test.exs index 3d55f5340..134e67bd9 100644 --- a/apps/language_server/test/providers/completion_test.exs +++ b/apps/language_server/test/providers/completion_test.exs @@ -20,8 +20,6 @@ defmodule ElixirLS.LanguageServer.Providers.CompletionTest do } setup context do - ElixirLS.LanguageServer.Build.load_all_modules() - unless context[:skip_server] do server = ElixirLS.LanguageServer.Test.ServerTestHelpers.start_server() @@ -130,6 +128,24 @@ defmodule ElixirLS.LanguageServer.Providers.CompletionTest do end end + test "returns fn autocompletion when inside parentheses" do + text = """ + defmodule MyModule do + + def dummy_function() do + Task.async(fn) + # ^ + end + end + """ + + {line, char} = {3, 17} + TestUtils.assert_has_cursor_char(text, line, char) + {:ok, %{"items" => [first_suggestion | _tail]}} = Completion.completion(text, line, char, @supports) + + assert first_suggestion["label"] === "fn" + end + test "unless with snippets not supported does not return a completion" do text = """ defmodule MyModule do @@ -370,6 +386,48 @@ defmodule ElixirLS.LanguageServer.Providers.CompletionTest do end describe "structs and maps" do + test "suggests full module path as additionalTextEdits" do + text = """ + defmodule MyModule do + @moduledoc \"\"\" + This + is a + long + moduledoc + + \"\"\" + + def dummy_function() do + ExampleS + # ^ + end + end + """ + + {line, char} = {10, 12} + TestUtils.assert_has_cursor_char(text, line, char) + + {:ok, %{"items" => items}} = Completion.completion(text, line, char, @supports) + + assert [item] = items + + # 22 is struct + assert item["kind"] == 22 + assert item["label"] == "ExampleStruct (struct)" + + assert [%{newText: "alias ElixirLS.LanguageServer.Fixtures.ExampleStruct\n"}] = + item["additionalTextEdits"] + + assert [ + %{ + range: %{ + "end" => %{"character" => 0, "line" => 8}, + "start" => %{"character" => 0, "line" => 8} + } + } + ] = item["additionalTextEdits"] + end + test "completions of structs are rendered as a struct" do text = """ defmodule MyModule do @@ -436,7 +494,7 @@ defmodule ElixirLS.LanguageServer.Providers.CompletionTest do defstruct [some: nil, other: 1] def dummy_function(var = %MyModule{}) do - %{var | + %{var | # ^ end end @@ -456,7 +514,7 @@ defmodule ElixirLS.LanguageServer.Providers.CompletionTest do text = """ defmodule MyModule do def dummy_function(var = %{some: nil, other: 1}) do - %{var | + %{var | # ^ end end @@ -908,6 +966,146 @@ defmodule ElixirLS.LanguageServer.Providers.CompletionTest do """ } end + + test "will suggest defmodule with module_name snippet when file path matches **/lib/**/*.ex" do + text = """ + defmod + # ^ + """ + + {line, char} = {0, 6} + + TestUtils.assert_has_cursor_char(text, line, char) + + assert {:ok, %{"items" => [first | _] = _items}} = + Completion.completion( + text, + line, + char, + @supports + |> Keyword.put( + :file_path, + "/some/path/my_project/lib/my_project/sub_folder/my_file.ex" + ) + ) + + assert %{ + "label" => "defmodule", + "insertText" => "defmodule MyProject.SubFolder.MyFile$1 do\n\t$0\nend" + } = first + end + + test "will suggest defmodule without module_name snippet when file path does not match expected patterns" do + text = """ + defmod + # ^ + """ + + {line, char} = {0, 6} + + TestUtils.assert_has_cursor_char(text, line, char) + + assert {:ok, %{"items" => [first | _] = _items}} = + Completion.completion( + text, + line, + char, + @supports + |> Keyword.put( + :file_path, + "/some/path/my_project/lib/my_project/sub_folder/my_file.heex" + ) + ) + + assert %{ + "label" => "defmodule", + "insertText" => "defmodule $1 do\n\t$0\nend" + } = first + end + + test "will suggest defmodule without module_name snippet when file path is nil" do + text = """ + defmod + # ^ + """ + + {line, char} = {0, 6} + + TestUtils.assert_has_cursor_char(text, line, char) + + assert {:ok, %{"items" => [first | _] = _items}} = + Completion.completion( + text, + line, + char, + @supports + |> Keyword.put( + :file_path, + nil + ) + ) + + assert %{ + "label" => "defmodule", + "insertText" => "defmodule $1 do\n\t$0\nend" + } = first + end + + test "will suggest defprotocol with protocol_name snippet when file path matches **/lib/**/*.ex" do + text = """ + defpro + # ^ + """ + + {line, char} = {0, 6} + + TestUtils.assert_has_cursor_char(text, line, char) + + assert {:ok, %{"items" => [first | _] = _items}} = + Completion.completion( + text, + line, + char, + @supports + |> Keyword.put( + :file_path, + "/some/path/my_project/lib/my_project/sub_folder/my_file.ex" + ) + ) + + assert %{ + "label" => "defprotocol", + "insertText" => "defprotocol MyProject.SubFolder.MyFile$1 do\n\t$0\nend" + } = first + end + + test "will suggest defprotocol without protocol_name snippet when file path does not match expected patterns" do + text = """ + defpro + # ^ + """ + + {line, char} = {0, 6} + + TestUtils.assert_has_cursor_char(text, line, char) + + assert {:ok, %{"items" => [first | _] = _items}} = + Completion.completion( + text, + line, + char, + @supports + |> Keyword.put( + :file_path, + "/some/path/my_project/lib/my_project/sub_folder/my_file.heex" + ) + ) + + assert %{ + "label" => "defprotocol", + "insertText" => "defprotocol $1 do\n\t$0\nend" + } = first + end end describe "generic suggestions" do @@ -1053,4 +1251,81 @@ defmodule ElixirLS.LanguageServer.Providers.CompletionTest do assert insert_text =~ "if do\n\t" end end + + describe "suggest_module_name/1" do + import Completion, only: [suggest_module_name: 1] + + test "returns nil if current file_path is empty" do + assert nil == suggest_module_name("") + end + + test "returns nil if current file is not an .ex file" do + assert nil == suggest_module_name("some/path/lib/dir/file.heex") + end + + test "returns nil if current file is an .ex file but no lib folder exists in path" do + assert nil == suggest_module_name("some/path/not_lib/dir/file.ex") + end + + test "returns nil if current file is an *_test.exs file but no test folder exists in path" do + assert nil == suggest_module_name("some/path/not_test/dir/file_test.exs") + end + + test "returns an appropriate suggestion if file directly under lib" do + assert "MyProject" == suggest_module_name("some/path/my_project/lib/my_project.ex") + end + + test "returns an appropriate suggestion if file arbitrarily nested under lib/" do + assert "MyProject.Foo.Bar.Baz.MyFile" = + suggest_module_name("some/path/my_project/lib/my_project/foo/bar/baz/my_file.ex") + end + + test "returns an appropriate suggestion if file directly under test/" do + assert "MyProjectTest" == + suggest_module_name("some/path/my_project/test/my_project_test.exs") + end + + test "returns an appropriate suggestion if file arbitrarily nested under test" do + assert "MyProject.Foo.Bar.Baz.MyFileTest" == + suggest_module_name( + "some/path/my_project/test/my_project/foo/bar/baz/my_file_test.exs" + ) + end + + test "returns an appropriate suggestion if file is part of an umbrella project" do + assert "MySubApp.Foo.Bar.Baz" == + suggest_module_name( + "some/path/my_umbrella_project/apps/my_sub_app/lib/my_sub_app/foo/bar/baz.ex" + ) + end + + test "returns appropriate suggestions for modules nested under known phoenix dirs" do + [ + {"MyProjectWeb.MyController", "controllers/my_controller.ex"}, + {"MyProjectWeb.MyPlug", "plugs/my_plug.ex"}, + {"MyProjectWeb.MyView", "views/my_view.ex"}, + {"MyProjectWeb.MyChannel", "channels/my_channel.ex"}, + {"MyProjectWeb.MyEndpoint", "endpoints/my_endpoint.ex"}, + {"MyProjectWeb.MySocket", "sockets/my_socket.ex"}, + {"MyProjectWeb.MyviewLive.MyComponent", "live/myview_live/my_component.ex"}, + {"MyProjectWeb.MyComponent", "components/my_component.ex"} + ] + |> Enum.each(fn {expected_module_name, partial_path} -> + path = "some/path/my_project/lib/my_project_web/#{partial_path}" + assert expected_module_name == suggest_module_name(path) + end) + end + + test "uses known Phoenix dirs as part of a module's name if these are not located directly beneath the *_web folder" do + assert "MyProject.Controllers.MyController" == + suggest_module_name( + "some/path/my_project/lib/my_project/controllers/my_controller.ex" + ) + + assert "MyProjectWeb.SomeNestedDir.Controllers.MyController" == + suggest_module_name( + "some/path/my_project/lib/my_project_web/some_nested_dir/controllers/my_controller.ex" + ) + end + end end diff --git a/apps/language_server/test/providers/definition_test.exs b/apps/language_server/test/providers/definition_test.exs index f5933fe26..9b0539e96 100644 --- a/apps/language_server/test/providers/definition_test.exs +++ b/apps/language_server/test/providers/definition_test.exs @@ -7,19 +7,19 @@ defmodule ElixirLS.LanguageServer.Providers.DefinitionTest do alias ElixirLS.LanguageServer.Test.FixtureHelpers require ElixirLS.Test.TextLoc - test "find definition" do - file_path = FixtureHelpers.get_path("references_a.ex") + test "find definition remote function call" do + file_path = FixtureHelpers.get_path("references_remote.ex") text = File.read!(file_path) - uri = SourceFile.path_to_uri(file_path) + uri = SourceFile.Path.to_uri(file_path) - b_file_path = FixtureHelpers.get_path("references_b.ex") - b_uri = SourceFile.path_to_uri(b_file_path) + b_file_path = FixtureHelpers.get_path("references_referenced.ex") + b_uri = SourceFile.Path.to_uri(b_file_path) - {line, char} = {2, 30} + {line, char} = {4, 28} ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ - ElixirLS.Test.ReferencesB.b_fun() - ^ + ReferencesReferenced.referenced_fun() + ^ """) assert {:ok, %Location{uri: ^b_uri, range: range}} = @@ -30,4 +30,172 @@ defmodule ElixirLS.LanguageServer.Providers.DefinitionTest do "end" => %{"line" => 1, "character" => 6} } end + + test "find definition remote macro call" do + file_path = FixtureHelpers.get_path("references_remote.ex") + text = File.read!(file_path) + uri = SourceFile.Path.to_uri(file_path) + + b_file_path = FixtureHelpers.get_path("references_referenced.ex") + b_uri = SourceFile.Path.to_uri(b_file_path) + + {line, char} = {8, 28} + + ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ + ReferencesReferenced.referenced_macro a do + ^ + """) + + assert {:ok, %Location{uri: ^b_uri, range: range}} = + Definition.definition(uri, text, line, char) + + assert range == %{ + "start" => %{"line" => 8, "character" => 11}, + "end" => %{"line" => 8, "character" => 11} + } + end + + test "find definition imported function call" do + file_path = FixtureHelpers.get_path("references_imported.ex") + text = File.read!(file_path) + uri = SourceFile.Path.to_uri(file_path) + + b_file_path = FixtureHelpers.get_path("references_referenced.ex") + b_uri = SourceFile.Path.to_uri(b_file_path) + + {line, char} = {4, 5} + + ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ + referenced_fun() + ^ + """) + + assert {:ok, %Location{uri: ^b_uri, range: range}} = + Definition.definition(uri, text, line, char) + + assert range == %{ + "start" => %{"line" => 1, "character" => 6}, + "end" => %{"line" => 1, "character" => 6} + } + end + + test "find definition imported macro call" do + file_path = FixtureHelpers.get_path("references_imported.ex") + text = File.read!(file_path) + uri = SourceFile.Path.to_uri(file_path) + + b_file_path = FixtureHelpers.get_path("references_referenced.ex") + b_uri = SourceFile.Path.to_uri(b_file_path) + + {line, char} = {8, 5} + + ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ + referenced_macro a do + ^ + """) + + assert {:ok, %Location{uri: ^b_uri, range: range}} = + Definition.definition(uri, text, line, char) + + assert range == %{ + "start" => %{"line" => 8, "character" => 11}, + "end" => %{"line" => 8, "character" => 11} + } + end + + test "find definition local function call" do + file_path = FixtureHelpers.get_path("references_referenced.ex") + text = File.read!(file_path) + uri = SourceFile.Path.to_uri(file_path) + + b_file_path = FixtureHelpers.get_path("references_referenced.ex") + b_uri = SourceFile.Path.to_uri(b_file_path) + + {line, char} = {15, 5} + + ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ + referenced_fun() + ^ + """) + + assert {:ok, %Location{uri: ^b_uri, range: range}} = + Definition.definition(uri, text, line, char) + + assert range == %{ + "start" => %{"line" => 1, "character" => 6}, + "end" => %{"line" => 1, "character" => 6} + } + end + + test "find definition local macro call" do + file_path = FixtureHelpers.get_path("references_referenced.ex") + text = File.read!(file_path) + uri = SourceFile.Path.to_uri(file_path) + + b_file_path = FixtureHelpers.get_path("references_referenced.ex") + b_uri = SourceFile.Path.to_uri(b_file_path) + + {line, char} = {19, 5} + + ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ + referenced_macro a do + ^ + """) + + assert {:ok, %Location{uri: ^b_uri, range: range}} = + Definition.definition(uri, text, line, char) + + assert range == %{ + "start" => %{"line" => 8, "character" => 11}, + "end" => %{"line" => 8, "character" => 11} + } + end + + test "find definition variable" do + file_path = FixtureHelpers.get_path("references_referenced.ex") + text = File.read!(file_path) + uri = SourceFile.Path.to_uri(file_path) + + b_file_path = FixtureHelpers.get_path("references_referenced.ex") + b_uri = SourceFile.Path.to_uri(b_file_path) + + {line, char} = {4, 13} + + ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ + IO.puts(referenced_variable + 1) + ^ + """) + + assert {:ok, %Location{uri: ^b_uri, range: range}} = + Definition.definition(uri, text, line, char) + + assert range == %{ + "start" => %{"line" => 2, "character" => 4}, + "end" => %{"line" => 2, "character" => 4} + } + end + + test "find definition attribute" do + file_path = FixtureHelpers.get_path("references_referenced.ex") + text = File.read!(file_path) + uri = SourceFile.Path.to_uri(file_path) + + b_file_path = FixtureHelpers.get_path("references_referenced.ex") + b_uri = SourceFile.Path.to_uri(b_file_path) + + {line, char} = {27, 5} + + ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ + @referenced_attribute + ^ + """) + + assert {:ok, %Location{uri: ^b_uri, range: range}} = + Definition.definition(uri, text, line, char) + + assert range == %{ + "start" => %{"line" => 24, "character" => 2}, + "end" => %{"line" => 24, "character" => 2} + } + end end diff --git a/apps/language_server/test/providers/document_symbols_test.exs b/apps/language_server/test/providers/document_symbols_test.exs index 920d9e1f7..e7b235a1c 100644 --- a/apps/language_server/test/providers/document_symbols_test.exs +++ b/apps/language_server/test/providers/document_symbols_test.exs @@ -30,6 +30,16 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do after :ok end + def fun_multiple_when(term \\ nil) + def fun_multiple_when(term) + when is_integer(term) + when is_float(term) + when is_nil(term) do + :maybe_number + end + def fun_multiple_when(_other) do + :something_else + end end ] @@ -41,127 +51,160 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do children: [], kind: 14, name: "@my_mod_var", - range: %{end: %{character: 9, line: 2}, start: %{character: 9, line: 2}}, + range: %{ + "end" => %{"character" => 37, "line" => 2}, + "start" => %{"character" => 8, "line" => 2} + }, selectionRange: %{ - end: %{character: 9, line: 2}, - start: %{character: 9, line: 2} + "end" => %{"character" => 37, "line" => 2}, + "start" => %{"character" => 8, "line" => 2} } }, %Protocol.DocumentSymbol{ children: [], kind: 12, name: "def my_fn(arg)", - range: %{end: %{character: 12, line: 3}, start: %{character: 12, line: 3}}, + range: %{ + "end" => %{"character" => 31, "line" => 3}, + "start" => %{"character" => 8, "line" => 3} + }, selectionRange: %{ - end: %{character: 12, line: 3}, - start: %{character: 12, line: 3} + "end" => %{"character" => 22, "line" => 3}, + "start" => %{"character" => 12, "line" => 3} } }, %Protocol.DocumentSymbol{ children: [], kind: 12, - name: "defp my_private_fn(arg)", - range: %{end: %{character: 13, line: 4}, start: %{character: 13, line: 4}}, - selectionRange: %{ - end: %{character: 13, line: 4}, - start: %{character: 13, line: 4} - } + name: "defp my_private_fn(arg)" }, %Protocol.DocumentSymbol{ children: [], kind: 12, - name: "defmacro my_macro()", - range: %{end: %{character: 17, line: 5}, start: %{character: 17, line: 5}}, - selectionRange: %{ - end: %{character: 17, line: 5}, - start: %{character: 17, line: 5} - } + name: "defmacro my_macro()" }, %Protocol.DocumentSymbol{ children: [], kind: 12, - name: "defmacrop my_private_macro()", - range: %{end: %{character: 18, line: 6}, start: %{character: 18, line: 6}}, - selectionRange: %{ - end: %{character: 18, line: 6}, - start: %{character: 18, line: 6} - } + name: "defmacrop my_private_macro()" }, %Protocol.DocumentSymbol{ children: [], kind: 12, name: "defguard my_guard(a)", - range: %{end: %{character: 17, line: 7}, start: %{character: 17, line: 7}}, + range: %{ + "end" => %{"character" => 47, "line" => 7}, + "start" => %{"character" => 8, "line" => 7} + }, selectionRange: %{ - end: %{character: 17, line: 7}, - start: %{character: 17, line: 7} + "end" => %{"character" => 28, "line" => 7}, + "start" => %{"character" => 17, "line" => 7} } }, %Protocol.DocumentSymbol{ children: [], kind: 12, - name: "defguardp my_private_guard(a)", - range: %{end: %{character: 18, line: 8}, start: %{character: 18, line: 8}}, + name: "defguardp my_private_guard(a)" + }, + %Protocol.DocumentSymbol{ + children: [], + kind: 12, + name: "defdelegate my_delegate(list)", + range: %{ + "end" => %{"character" => 61, "line" => 9}, + "start" => %{"character" => 8, "line" => 9} + }, selectionRange: %{ - end: %{character: 18, line: 8}, - start: %{character: 18, line: 8} + "end" => %{"character" => 37, "line" => 9}, + "start" => %{"character" => 20, "line" => 9} } }, %Protocol.DocumentSymbol{ children: [], kind: 12, - name: "defdelegate my_delegate(list)", - range: %{end: %{character: 20, line: 9}, start: %{character: 20, line: 9}}, + name: "defguard my_guard" + }, + %Protocol.DocumentSymbol{ + children: [], + kind: 12, + name: "def my_fn_no_arg", + range: %{ + "end" => %{"character" => 33, "line" => 11}, + "start" => %{"character" => 8, "line" => 11} + }, selectionRange: %{ - end: %{character: 20, line: 9}, - start: %{character: 20, line: 9} + "end" => %{"character" => 24, "line" => 11}, + "start" => %{"character" => 12, "line" => 11} } }, %Protocol.DocumentSymbol{ children: [], kind: 12, - name: "defguard my_guard", - range: %{end: %{character: 17, line: 10}, start: %{character: 17, line: 10}}, + name: "def my_fn_with_guard(arg)" + }, + %Protocol.DocumentSymbol{ + children: [], + kind: 12, + name: "def my_fn_with_more_blocks(arg)", + range: %{ + "end" => %{"character" => 11, "line" => 23}, + "start" => %{"character" => 8, "line" => 13} + }, selectionRange: %{ - end: %{character: 17, line: 10}, - start: %{character: 17, line: 10} + "end" => %{"character" => 39, "line" => 13}, + "start" => %{"character" => 12, "line" => 13} } }, %Protocol.DocumentSymbol{ children: [], kind: 12, - name: "def my_fn_no_arg", - range: %{end: %{character: 12, line: 11}, start: %{character: 12, line: 11}}, + name: "def fun_multiple_when(term \\\\ nil)", + range: %{ + "end" => %{"character" => 42, "line" => 24}, + "start" => %{"character" => 8, "line" => 24} + }, selectionRange: %{ - end: %{character: 12, line: 11}, - start: %{character: 12, line: 11} + "end" => %{"character" => 42, "line" => 24}, + "start" => %{"character" => 12, "line" => 24} } }, %Protocol.DocumentSymbol{ children: [], kind: 12, - name: "def my_fn_with_guard(arg)", - range: %{end: %{character: 12, line: 12}, start: %{character: 12, line: 12}}, + name: "def fun_multiple_when(term)", + range: %{ + "end" => %{"character" => 11, "line" => 30}, + "start" => %{"character" => 8, "line" => 25} + }, selectionRange: %{ - end: %{character: 12, line: 12}, - start: %{character: 12, line: 12} + "end" => %{"character" => 35, "line" => 25}, + "start" => %{"character" => 12, "line" => 25} } }, %Protocol.DocumentSymbol{ children: [], kind: 12, - name: "def my_fn_with_more_blocks(arg)", - range: %{end: %{character: 12, line: 13}, start: %{character: 12, line: 13}}, + name: "def fun_multiple_when(_other)", + range: %{ + "end" => %{"character" => 11, "line" => 33}, + "start" => %{"character" => 8, "line" => 31} + }, selectionRange: %{ - end: %{character: 12, line: 13}, - start: %{character: 12, line: 13} + "end" => %{"character" => 37, "line" => 31}, + "start" => %{"character" => 12, "line" => 31} } } ], kind: 2, name: "MyModule", - range: %{end: %{character: 6, line: 1}, start: %{character: 6, line: 1}}, - selectionRange: %{end: %{character: 6, line: 1}, start: %{character: 6, line: 1}} + range: %{ + "end" => %{"character" => 9, "line" => 34}, + "start" => %{"character" => 6, "line" => 1} + }, + selectionRange: %{ + "end" => %{"character" => 24, "line" => 1}, + "start" => %{"character" => 16, "line" => 1} + } } ]} = DocumentSymbols.symbols(uri, text, true) end @@ -201,14 +244,20 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do name: "MyModule", kind: 2, location: %{ - range: %{end: %{character: 6, line: 1}, start: %{character: 6, line: 1}} + range: %{ + "end" => %{"character" => 9, "line" => 24}, + "start" => %{"character" => 6, "line" => 1} + } } }, %Protocol.SymbolInformation{ name: "@my_mod_var", kind: 14, location: %{ - range: %{end: %{character: 9, line: 2}, start: %{character: 9, line: 2}} + range: %{ + "end" => %{"character" => 37, "line" => 2}, + "start" => %{"character" => 8, "line" => 2} + } }, containerName: "MyModule" }, @@ -216,87 +265,78 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do name: "def my_fn(arg)", kind: 12, location: %{ - range: %{end: %{character: 12, line: 3}, start: %{character: 12, line: 3}} + range: %{ + "end" => %{"character" => 31, "line" => 3}, + "start" => %{"character" => 8, "line" => 3} + } }, containerName: "MyModule" }, %Protocol.SymbolInformation{ name: "defp my_private_fn(arg)", kind: 12, - location: %{ - range: %{end: %{character: 13, line: 4}, start: %{character: 13, line: 4}} - }, containerName: "MyModule" }, %Protocol.SymbolInformation{ name: "defmacro my_macro()", kind: 12, - location: %{ - range: %{end: %{character: 17, line: 5}, start: %{character: 17, line: 5}} - }, containerName: "MyModule" }, %Protocol.SymbolInformation{ name: "defmacrop my_private_macro()", kind: 12, - location: %{ - range: %{end: %{character: 18, line: 6}, start: %{character: 18, line: 6}} - }, containerName: "MyModule" }, %Protocol.SymbolInformation{ name: "defguard my_guard(a)", kind: 12, location: %{ - range: %{end: %{character: 17, line: 7}, start: %{character: 17, line: 7}} + range: %{ + "end" => %{"character" => 47, "line" => 7}, + "start" => %{"character" => 8, "line" => 7} + } }, containerName: "MyModule" }, %Protocol.SymbolInformation{ name: "defguardp my_private_guard(a)", kind: 12, - location: %{ - range: %{end: %{character: 18, line: 8}, start: %{character: 18, line: 8}} - }, containerName: "MyModule" }, %Protocol.SymbolInformation{ name: "defdelegate my_delegate(list)", kind: 12, location: %{ - range: %{end: %{character: 20, line: 9}, start: %{character: 20, line: 9}} + range: %{ + "end" => %{"character" => 61, "line" => 9}, + "start" => %{"character" => 8, "line" => 9} + } }, containerName: "MyModule" }, %Protocol.SymbolInformation{ name: "defguard my_guard", kind: 12, - location: %{ - range: %{end: %{character: 17, line: 10}, start: %{character: 17, line: 10}} - }, containerName: "MyModule" }, %Protocol.SymbolInformation{ name: "def my_fn_no_arg", kind: 12, - location: %{ - range: %{end: %{character: 12, line: 11}, start: %{character: 12, line: 11}} - }, containerName: "MyModule" }, %Protocol.SymbolInformation{ name: "def my_fn_with_guard(arg)", kind: 12, - location: %{ - range: %{end: %{character: 12, line: 12}, start: %{character: 12, line: 12}} - }, containerName: "MyModule" }, %Protocol.SymbolInformation{ name: "def my_fn_with_more_blocks(arg)", kind: 12, location: %{ - range: %{end: %{character: 12, line: 13}, start: %{character: 12, line: 13}} + range: %{ + "end" => %{"character" => 11, "line" => 23}, + "start" => %{"character" => 8, "line" => 13} + } }, containerName: "MyModule" } @@ -307,7 +347,7 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do uri = "file:///project/file.ex" text = ~S[ defmodule MyModule do - defmodule SubModule do + defmodule Sub.Module do def my_fn(), do: :ok end end @@ -322,38 +362,30 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do %Protocol.DocumentSymbol{ children: [], kind: 12, - name: "def my_fn()", - range: %{ - end: %{character: 14, line: 3}, - start: %{character: 14, line: 3} - }, - selectionRange: %{ - end: %{character: 14, line: 3}, - start: %{character: 14, line: 3} - } + name: "def my_fn()" } ], kind: 2, - name: "SubModule", + name: "Sub.Module", range: %{ - end: %{character: 8, line: 2}, - start: %{character: 8, line: 2} + "end" => %{"character" => 11, "line" => 4}, + "start" => %{"character" => 8, "line" => 2} }, selectionRange: %{ - end: %{character: 8, line: 2}, - start: %{character: 8, line: 2} + "end" => %{"character" => 28, "line" => 2}, + "start" => %{"character" => 18, "line" => 2} } } ], kind: 2, name: "MyModule", range: %{ - end: %{character: 6, line: 1}, - start: %{character: 6, line: 1} + "end" => %{"character" => 9, "line" => 5}, + "start" => %{"character" => 6, "line" => 1} }, selectionRange: %{ - end: %{character: 6, line: 1}, - start: %{character: 6, line: 1} + "end" => %{"character" => 24, "line" => 1}, + "start" => %{"character" => 16, "line" => 1} } } ]} = DocumentSymbols.symbols(uri, text, true) @@ -376,8 +408,8 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do kind: 2, location: %{ range: %{ - end: %{character: 6, line: 1}, - start: %{character: 6, line: 1} + "end" => %{"character" => 9, "line" => 5}, + "start" => %{"character" => 6, "line" => 1} } } }, @@ -386,8 +418,8 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do kind: 2, location: %{ range: %{ - end: %{character: 8, line: 2}, - start: %{character: 8, line: 2} + "end" => %{"character" => 11, "line" => 4}, + "start" => %{"character" => 8, "line" => 2} } }, containerName: "MyModule" @@ -395,12 +427,6 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do %Protocol.SymbolInformation{ kind: 12, name: "def my_fn()", - location: %{ - range: %{ - end: %{character: 14, line: 3}, - start: %{character: 14, line: 3} - } - }, containerName: "SubModule" } ]} = DocumentSymbols.symbols(uri, text, false) @@ -424,26 +450,18 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do %Protocol.DocumentSymbol{ children: [], kind: 12, - name: "def some_function()", - range: %{ - end: %{character: 12, line: 2}, - start: %{character: 12, line: 2} - }, - selectionRange: %{ - end: %{character: 12, line: 2}, - start: %{character: 12, line: 2} - } + name: "def some_function()" } ], kind: 2, name: "MyModule", range: %{ - end: %{character: 6, line: 1}, - start: %{character: 6, line: 1} + "end" => %{"character" => 9, "line" => 3}, + "start" => %{"character" => 6, "line" => 1} }, selectionRange: %{ - end: %{character: 6, line: 1}, - start: %{character: 6, line: 1} + "end" => %{"character" => 24, "line" => 1}, + "start" => %{"character" => 16, "line" => 1} } }, %Protocol.DocumentSymbol{ @@ -451,26 +469,18 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do %Protocol.DocumentSymbol{ children: [], kind: 12, - name: "def some_other_function()", - range: %{ - end: %{character: 12, line: 5}, - start: %{character: 12, line: 5} - }, - selectionRange: %{ - end: %{character: 12, line: 5}, - start: %{character: 12, line: 5} - } + name: "def some_other_function()" } ], kind: 2, name: "MyOtherModule", range: %{ - end: %{character: 6, line: 4}, - start: %{character: 6, line: 4} + "end" => %{"character" => 9, "line" => 6}, + "start" => %{"character" => 6, "line" => 4} }, selectionRange: %{ - end: %{character: 6, line: 4}, - start: %{character: 6, line: 4} + "end" => %{"character" => 29, "line" => 4}, + "start" => %{"character" => 16, "line" => 4} } } ]} = DocumentSymbols.symbols(uri, text, true) @@ -494,20 +504,14 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do kind: 2, location: %{ range: %{ - end: %{character: 6, line: 1}, - start: %{character: 6, line: 1} + "end" => %{"character" => 9, "line" => 3}, + "start" => %{"character" => 6, "line" => 1} } } }, %Protocol.SymbolInformation{ name: "def some_function()", kind: 12, - location: %{ - range: %{ - end: %{character: 12, line: 2}, - start: %{character: 12, line: 2} - } - }, containerName: "MyModule" }, %Protocol.SymbolInformation{ @@ -515,20 +519,14 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do kind: 2, location: %{ range: %{ - end: %{character: 6, line: 4}, - start: %{character: 6, line: 4} + "end" => %{"character" => 9, "line" => 6}, + "start" => %{"character" => 6, "line" => 4} } } }, %Protocol.SymbolInformation{ kind: 12, name: "def some_other_function()", - location: %{ - range: %{ - end: %{character: 12, line: 5}, - start: %{character: 12, line: 5} - } - }, containerName: "MyOtherModule" } ]} = DocumentSymbols.symbols(uri, text, false) @@ -549,18 +547,19 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do %Protocol.DocumentSymbol{ children: [], kind: 12, - name: "def my_fn()", - range: %{end: %{character: 12, line: 2}, start: %{character: 12, line: 2}}, - selectionRange: %{ - end: %{character: 12, line: 2}, - start: %{character: 12, line: 2} - } + name: "def my_fn()" } ], kind: 2, name: "MyModule", - range: %{end: %{character: 6, line: 1}, start: %{character: 6, line: 1}}, - selectionRange: %{end: %{character: 6, line: 1}, start: %{character: 6, line: 1}} + range: %{ + "end" => %{"character" => 9, "line" => 3}, + "start" => %{"character" => 6, "line" => 1} + }, + selectionRange: %{ + "end" => %{"character" => 9, "line" => 3}, + "start" => %{"character" => 6, "line" => 1} + } } ]} = DocumentSymbols.symbols(uri, text, true) end @@ -579,15 +578,15 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do name: "MyModule", kind: 2, location: %{ - range: %{end: %{character: 6, line: 1}, start: %{character: 6, line: 1}} + range: %{ + "end" => %{"character" => 9, "line" => 3}, + "start" => %{"character" => 6, "line" => 1} + } } }, %Protocol.SymbolInformation{ name: "def my_fn()", kind: 12, - location: %{ - range: %{end: %{character: 12, line: 2}, start: %{character: 12, line: 2}} - }, containerName: "MyModule" } ]} = DocumentSymbols.symbols(uri, text, false) @@ -608,18 +607,19 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do %Protocol.DocumentSymbol{ children: [], kind: 12, - name: "def my_fn()", - range: %{end: %{character: 12, line: 2}, start: %{character: 12, line: 2}}, - selectionRange: %{ - end: %{character: 12, line: 2}, - start: %{character: 12, line: 2} - } + name: "def my_fn()" } ], kind: 2, name: "# unknown", - range: %{end: %{character: 6, line: 1}, start: %{character: 6, line: 1}}, - selectionRange: %{end: %{character: 6, line: 1}, start: %{character: 6, line: 1}} + range: %{ + "end" => %{"character" => 9, "line" => 3}, + "start" => %{"character" => 6, "line" => 1} + }, + selectionRange: %{ + "end" => %{"character" => 28, "line" => 1}, + "start" => %{"character" => 16, "line" => 1} + } } ]} = DocumentSymbols.symbols(uri, text, true) end @@ -638,15 +638,15 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do name: "# unknown", kind: 2, location: %{ - range: %{end: %{character: 6, line: 1}, start: %{character: 6, line: 1}} + range: %{ + "end" => %{"character" => 9, "line" => 3}, + "start" => %{"character" => 6, "line" => 1} + } } }, %Protocol.SymbolInformation{ kind: 12, name: "def my_fn()", - location: %{ - range: %{end: %{character: 12, line: 2}, start: %{character: 12, line: 2}} - }, containerName: "# unknown" } ]} = DocumentSymbols.symbols(uri, text, false) @@ -667,18 +667,19 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do %Protocol.DocumentSymbol{ children: [], kind: 12, - name: "def my_fn()", - range: %{end: %{character: 12, line: 2}, start: %{character: 12, line: 2}}, - selectionRange: %{ - end: %{character: 12, line: 2}, - start: %{character: 12, line: 2} - } + name: "def my_fn()" } ], kind: 2, name: "my_module", - range: %{end: %{character: 6, line: 1}, start: %{character: 6, line: 1}}, - selectionRange: %{end: %{character: 6, line: 1}, start: %{character: 6, line: 1}} + range: %{ + "end" => %{"character" => 9, "line" => 3}, + "start" => %{"character" => 6, "line" => 1} + }, + selectionRange: %{ + "end" => %{"character" => 9, "line" => 3}, + "start" => %{"character" => 6, "line" => 1} + } } ]} = DocumentSymbols.symbols(uri, text, true) end @@ -697,15 +698,15 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do name: "my_module", kind: 2, location: %{ - range: %{end: %{character: 6, line: 1}, start: %{character: 6, line: 1}} + range: %{ + "end" => %{"character" => 9, "line" => 3}, + "start" => %{"character" => 6, "line" => 1} + } } }, %Protocol.SymbolInformation{ name: "def my_fn()", kind: 12, - location: %{ - range: %{end: %{character: 12, line: 2}, start: %{character: 12, line: 2}} - }, containerName: "my_module" } ]} = DocumentSymbols.symbols(uri, text, false) @@ -730,27 +731,15 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do %Protocol.DocumentSymbol{ children: [], kind: 12, - name: "def my_fn()", - range: %{end: %{character: 14, line: 3}, start: %{character: 14, line: 3}}, - selectionRange: %{ - end: %{character: 14, line: 3}, - start: %{character: 14, line: 3} - } + name: "def my_fn()" } ], kind: 2, - name: "__MODULE__.SubModule", - range: %{end: %{character: 8, line: 2}, start: %{character: 8, line: 2}}, - selectionRange: %{ - end: %{character: 8, line: 2}, - start: %{character: 8, line: 2} - } + name: "__MODULE__.SubModule" } ], kind: 2, - name: "__MODULE__", - range: %{end: %{character: 6, line: 1}, start: %{character: 6, line: 1}}, - selectionRange: %{end: %{character: 6, line: 1}, start: %{character: 6, line: 1}} + name: "__MODULE__" } ]} = DocumentSymbols.symbols(uri, text, true) end @@ -769,25 +758,16 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do [ %Protocol.SymbolInformation{ name: "__MODULE__", - kind: 2, - location: %{ - range: %{end: %{character: 6, line: 1}, start: %{character: 6, line: 1}} - } + kind: 2 }, %Protocol.SymbolInformation{ name: "__MODULE__.SubModule", kind: 2, - location: %{ - range: %{end: %{character: 8, line: 2}, start: %{character: 8, line: 2}} - }, containerName: "__MODULE__" }, %Protocol.SymbolInformation{ name: "def my_fn()", kind: 12, - location: %{ - range: %{end: %{character: 14, line: 3}, start: %{character: 14, line: 3}} - }, containerName: "__MODULE__.SubModule" } ]} = DocumentSymbols.symbols(uri, text, false) @@ -819,17 +799,26 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do children: [], kind: 12, name: "def size(data)", - range: %{end: %{character: 6, line: 2}, start: %{character: 6, line: 2}}, + range: %{ + "end" => %{"character" => 16, "line" => 2}, + "start" => %{"character" => 2, "line" => 2} + }, selectionRange: %{ - end: %{character: 6, line: 2}, - start: %{character: 6, line: 2} + "end" => %{"character" => 16, "line" => 2}, + "start" => %{"character" => 6, "line" => 2} } } ], kind: 11, name: "MyProtocol", - range: %{end: %{character: 0, line: 0}, start: %{character: 0, line: 0}}, - selectionRange: %{end: %{character: 0, line: 0}, start: %{character: 0, line: 0}} + range: %{ + "end" => %{"character" => 3, "line" => 3}, + "start" => %{"character" => 0, "line" => 0} + }, + selectionRange: %{ + "end" => %{"character" => 22, "line" => 0}, + "start" => %{"character" => 12, "line" => 0} + } }, %Protocol.DocumentSymbol{ children: [ @@ -837,17 +826,26 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do children: [], kind: 12, name: "def size(binary)", - range: %{end: %{character: 6, line: 6}, start: %{character: 6, line: 6}}, + range: %{ + "end" => %{"character" => 18, "line" => 6}, + "start" => %{"character" => 2, "line" => 6} + }, selectionRange: %{ - end: %{character: 6, line: 6}, - start: %{character: 6, line: 6} + "end" => %{"character" => 18, "line" => 6}, + "start" => %{"character" => 6, "line" => 6} } } ], kind: 2, name: "MyProtocol, for: BitString", - range: %{end: %{character: 0, line: 5}, start: %{character: 0, line: 5}}, - selectionRange: %{end: %{character: 0, line: 5}, start: %{character: 0, line: 5}} + range: %{ + "end" => %{"character" => 3, "line" => 7}, + "start" => %{"character" => 0, "line" => 5} + }, + selectionRange: %{ + "end" => %{"character" => 3, "line" => 7}, + "start" => %{"character" => 0, "line" => 5} + } }, %Protocol.DocumentSymbol{ children: [ @@ -855,17 +853,26 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do children: [], kind: 12, name: "def size(param)", - range: %{end: %{character: 6, line: 10}, start: %{character: 6, line: 10}}, + range: %{ + "end" => %{"character" => 17, "line" => 10}, + "start" => %{"character" => 2, "line" => 10} + }, selectionRange: %{ - end: %{character: 6, line: 10}, - start: %{character: 6, line: 10} + "end" => %{"character" => 17, "line" => 10}, + "start" => %{"character" => 6, "line" => 10} } } ], kind: 2, name: "MyProtocol, for: [List, MyList]", - range: %{end: %{character: 0, line: 9}, start: %{character: 0, line: 9}}, - selectionRange: %{end: %{character: 0, line: 9}, start: %{character: 0, line: 9}} + range: %{ + "end" => %{"character" => 3, "line" => 11}, + "start" => %{"character" => 0, "line" => 9} + }, + selectionRange: %{ + "end" => %{"character" => 3, "line" => 11}, + "start" => %{"character" => 0, "line" => 9} + } } ]} = DocumentSymbols.symbols(uri, text, true) end @@ -894,14 +901,20 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do name: "MyProtocol", kind: 11, location: %{ - range: %{end: %{character: 0, line: 0}, start: %{character: 0, line: 0}} + range: %{ + "end" => %{"character" => 3, "line" => 3}, + "start" => %{"character" => 0, "line" => 0} + } } }, %Protocol.SymbolInformation{ kind: 12, name: "def size(data)", location: %{ - range: %{end: %{character: 6, line: 2}, start: %{character: 6, line: 2}} + range: %{ + "end" => %{"character" => 2, "line" => 2}, + "start" => %{"character" => 2, "line" => 2} + } }, containerName: "MyProtocol" }, @@ -909,14 +922,20 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do kind: 2, name: "MyProtocol, for: BitString", location: %{ - range: %{end: %{character: 0, line: 5}, start: %{character: 0, line: 5}} + range: %{ + "end" => %{"character" => 3, "line" => 7}, + "start" => %{"character" => 0, "line" => 5} + } } }, %Protocol.SymbolInformation{ kind: 12, name: "def size(binary)", location: %{ - range: %{end: %{character: 6, line: 6}, start: %{character: 6, line: 6}} + range: %{ + "end" => %{"character" => 2, "line" => 6}, + "start" => %{"character" => 2, "line" => 6} + } }, containerName: "MyProtocol, for: BitString" }, @@ -924,14 +943,20 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do kind: 2, name: "MyProtocol, for: [List, MyList]", location: %{ - range: %{end: %{character: 0, line: 9}, start: %{character: 0, line: 9}} + range: %{ + "end" => %{"character" => 3, "line" => 11}, + "start" => %{"character" => 0, "line" => 9} + } } }, %Protocol.SymbolInformation{ kind: 12, name: "def size(param)", location: %{ - range: %{end: %{character: 6, line: 10}, start: %{character: 6, line: 10}} + range: %{ + "end" => %{"character" => 2, "line" => 10}, + "start" => %{"character" => 2, "line" => 10} + } }, containerName: "MyProtocol, for: [List, MyList]" } @@ -957,36 +982,43 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do children: [], kind: 7, name: "prop", - range: %{end: %{character: 2, line: 1}, start: %{character: 2, line: 1}}, + range: %{ + "end" => %{"character" => 2, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} + }, selectionRange: %{ - end: %{character: 2, line: 1}, - start: %{character: 2, line: 1} + "end" => %{"character" => 2, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} } }, %Protocol.DocumentSymbol{ children: [], kind: 7, name: "prop_with_def", - range: %{end: %{character: 2, line: 1}, start: %{character: 2, line: 1}}, + range: %{ + "end" => %{"character" => 2, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} + }, selectionRange: %{ - end: %{character: 2, line: 1}, - start: %{character: 2, line: 1} + "end" => %{"character" => 2, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} } } ], kind: 23, - name: "struct", - range: %{end: %{character: 2, line: 1}, start: %{character: 2, line: 1}}, + name: "defstruct MyModule", + range: %{ + "end" => %{"character" => 2, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} + }, selectionRange: %{ - end: %{character: 2, line: 1}, - start: %{character: 2, line: 1} + "end" => %{"character" => 2, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} } } ], kind: 2, - name: "MyModule", - range: %{end: %{character: 0, line: 0}, start: %{character: 0, line: 0}}, - selectionRange: %{end: %{character: 0, line: 0}, start: %{character: 0, line: 0}} + name: "MyModule" } ]} = DocumentSymbols.symbols(uri, text, true) end @@ -1004,16 +1036,16 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do [ %Protocol.SymbolInformation{ name: "MyModule", - kind: 2, - location: %{ - range: %{end: %{character: 0, line: 0}, start: %{character: 0, line: 0}} - } + kind: 2 }, %Protocol.SymbolInformation{ - name: "struct", + name: "defstruct MyModule", kind: 23, location: %{ - range: %{end: %{character: 2, line: 1}, start: %{character: 2, line: 1}} + range: %{ + "end" => %{"character" => 2, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} + } }, containerName: "MyModule" }, @@ -1021,17 +1053,23 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do name: "prop", kind: 7, location: %{ - range: %{end: %{character: 2, line: 1}, start: %{character: 2, line: 1}} + range: %{ + "end" => %{"character" => 2, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} + } }, - containerName: "struct" + containerName: "defstruct MyModule" }, %Protocol.SymbolInformation{ kind: 7, name: "prop_with_def", location: %{ - range: %{end: %{character: 2, line: 1}, start: %{character: 2, line: 1}} + range: %{ + "end" => %{"character" => 2, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} + } }, - containerName: "struct" + containerName: "defstruct MyModule" } ]} = DocumentSymbols.symbols(uri, text, false) end @@ -1055,26 +1093,30 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do children: [], kind: 7, name: "message", - range: %{end: %{character: 2, line: 1}, start: %{character: 2, line: 1}}, + range: %{ + "end" => %{"character" => 2, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} + }, selectionRange: %{ - end: %{character: 2, line: 1}, - start: %{character: 2, line: 1} + "end" => %{"character" => 2, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} } } ], kind: 23, - name: "exception", - range: %{end: %{character: 2, line: 1}, start: %{character: 2, line: 1}}, + name: "defexception MyError", + range: %{ + "end" => %{"character" => 2, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} + }, selectionRange: %{ - end: %{character: 2, line: 1}, - start: %{character: 2, line: 1} + "end" => %{"character" => 2, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} } } ], kind: 2, - name: "MyError", - range: %{end: %{character: 0, line: 0}, start: %{character: 0, line: 0}}, - selectionRange: %{end: %{character: 0, line: 0}, start: %{character: 0, line: 0}} + name: "MyError" } ]} = DocumentSymbols.symbols(uri, text, true) end @@ -1092,16 +1134,16 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do [ %Protocol.SymbolInformation{ name: "MyError", - kind: 2, - location: %{ - range: %{end: %{character: 0, line: 0}, start: %{character: 0, line: 0}} - } + kind: 2 }, %Protocol.SymbolInformation{ kind: 23, - name: "exception", + name: "defexception MyError", location: %{ - range: %{end: %{character: 2, line: 1}, start: %{character: 2, line: 1}} + range: %{ + "end" => %{"character" => 2, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} + } }, containerName: "MyError" }, @@ -1109,9 +1151,12 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do kind: 7, name: "message", location: %{ - range: %{end: %{character: 2, line: 1}, start: %{character: 2, line: 1}} + range: %{ + "end" => %{"character" => 2, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} + } }, - containerName: "exception" + containerName: "defexception MyError" } ]} = DocumentSymbols.symbols(uri, text, false) end @@ -1137,68 +1182,44 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do %{ children: [], kind: 5, - name: "my_simple", - range: %{end: %{character: 3, line: 1}, start: %{character: 3, line: 1}}, + name: "@type my_simple", + range: %{ + "end" => %{"character" => 28, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} + }, selectionRange: %{ - end: %{character: 3, line: 1}, - start: %{character: 3, line: 1} + "end" => %{"character" => 17, "line" => 1}, + "start" => %{"character" => 8, "line" => 1} } }, %Protocol.DocumentSymbol{ children: [], kind: 5, - name: "my_union", - range: %{end: %{character: 3, line: 2}, start: %{character: 3, line: 2}}, - selectionRange: %{ - end: %{character: 3, line: 2}, - start: %{character: 3, line: 2} - } + name: "@type my_union" }, %Protocol.DocumentSymbol{ children: [], kind: 5, - name: "my_simple_private", - range: %{end: %{character: 3, line: 3}, start: %{character: 3, line: 3}}, - selectionRange: %{ - end: %{character: 3, line: 3}, - start: %{character: 3, line: 3} - } + name: "@typep my_simple_private" }, %Protocol.DocumentSymbol{ children: [], kind: 5, - name: "my_simple_opaque", - range: %{end: %{character: 3, line: 4}, start: %{character: 3, line: 4}}, - selectionRange: %{ - end: %{character: 3, line: 4}, - start: %{character: 3, line: 4} - } + name: "@opaque my_simple_opaque" }, %Protocol.DocumentSymbol{ children: [], kind: 5, - name: "my_with_args(key, value)", - range: %{end: %{character: 3, line: 5}, start: %{character: 3, line: 5}}, - selectionRange: %{ - end: %{character: 3, line: 5}, - start: %{character: 3, line: 5} - } + name: "@type my_with_args(key, value)" }, %Protocol.DocumentSymbol{ children: [], kind: 5, - name: "my_with_args_when(key, value)", - range: %{end: %{character: 3, line: 6}, start: %{character: 3, line: 6}}, - selectionRange: %{ - end: %{character: 3, line: 6}, - start: %{character: 3, line: 6} - } + name: "@type my_with_args_when(key, value)" } ], kind: 2, - name: "MyModule", - range: %{end: %{character: 0, line: 0}, start: %{character: 0, line: 0}}, - selectionRange: %{end: %{character: 0, line: 0}, start: %{character: 0, line: 0}} + name: "MyModule" } ]} = DocumentSymbols.symbols(uri, text, true) end @@ -1221,57 +1242,42 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do [ %Protocol.SymbolInformation{ name: "MyModule", - kind: 2, - location: %{ - range: %{end: %{character: 0, line: 0}, start: %{character: 0, line: 0}} - } + kind: 2 }, %Protocol.SymbolInformation{ kind: 5, - name: "my_simple", + name: "@type my_simple", location: %{ - range: %{end: %{character: 3, line: 1}, start: %{character: 3, line: 1}} + range: %{ + "end" => %{"character" => 28, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} + } }, containerName: "MyModule" }, %Protocol.SymbolInformation{ kind: 5, - name: "my_union", - location: %{ - range: %{end: %{character: 3, line: 2}, start: %{character: 3, line: 2}} - }, + name: "@type my_union", containerName: "MyModule" }, %Protocol.SymbolInformation{ kind: 5, - name: "my_simple_private", - location: %{ - range: %{end: %{character: 3, line: 3}, start: %{character: 3, line: 3}} - }, + name: "@typep my_simple_private", containerName: "MyModule" }, %Protocol.SymbolInformation{ kind: 5, - name: "my_simple_opaque", - location: %{ - range: %{end: %{character: 3, line: 4}, start: %{character: 3, line: 4}} - }, + name: "@opaque my_simple_opaque", containerName: "MyModule" }, %Protocol.SymbolInformation{ kind: 5, - name: "my_with_args(key, value)", - location: %{ - range: %{end: %{character: 3, line: 5}, start: %{character: 3, line: 5}} - }, + name: "@type my_with_args(key, value)", containerName: "MyModule" }, %Protocol.SymbolInformation{ kind: 5, - name: "my_with_args_when(key, value)", - location: %{ - range: %{end: %{character: 3, line: 6}, start: %{character: 3, line: 6}} - }, + name: "@type my_with_args_when(key, value)", containerName: "MyModule" } ]} = DocumentSymbols.symbols(uri, text, false) @@ -1300,68 +1306,44 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do %Protocol.DocumentSymbol{ children: [], kind: 24, - name: "my_callback(type1, type2)", - range: %{end: %{character: 3, line: 1}, start: %{character: 3, line: 1}}, + name: "@callback my_callback(type1, type2)", + range: %{ + "end" => %{"character" => 52, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} + }, selectionRange: %{ - end: %{character: 3, line: 1}, - start: %{character: 3, line: 1} + "end" => %{"character" => 37, "line" => 1}, + "start" => %{"character" => 12, "line" => 1} } }, %Protocol.DocumentSymbol{ children: [], kind: 24, - name: "my_macrocallback(type1, type2)", - range: %{end: %{character: 3, line: 2}, start: %{character: 3, line: 2}}, - selectionRange: %{ - end: %{character: 3, line: 2}, - start: %{character: 3, line: 2} - } + name: "@macrocallback my_macrocallback(type1, type2)" }, %Protocol.DocumentSymbol{ children: [], kind: 24, - name: "my_callback_when(type1, type2)", - range: %{end: %{character: 3, line: 4}, start: %{character: 3, line: 4}}, - selectionRange: %{ - end: %{character: 3, line: 4}, - start: %{character: 3, line: 4} - } + name: "@callback my_callback_when(type1, type2)" }, %Protocol.DocumentSymbol{ children: [], kind: 24, - name: "my_macrocallback_when(type1, type2)", - range: %{end: %{character: 3, line: 5}, start: %{character: 3, line: 5}}, - selectionRange: %{ - end: %{character: 3, line: 5}, - start: %{character: 3, line: 5} - } + name: "@macrocallback my_macrocallback_when(type1, type2)" }, %Protocol.DocumentSymbol{ children: [], kind: 24, - name: "my_callback_no_arg()", - range: %{end: %{character: 3, line: 7}, start: %{character: 3, line: 7}}, - selectionRange: %{ - end: %{character: 3, line: 7}, - start: %{character: 3, line: 7} - } + name: "@callback my_callback_no_arg()" }, %Protocol.DocumentSymbol{ children: [], kind: 24, - name: "my_macrocallback_no_arg()", - range: %{end: %{character: 3, line: 8}, start: %{character: 3, line: 8}}, - selectionRange: %{ - end: %{character: 3, line: 8}, - start: %{character: 3, line: 8} - } + name: "@macrocallback my_macrocallback_no_arg()" } ], kind: 2, - name: "MyModule", - range: %{end: %{character: 0, line: 0}, start: %{character: 0, line: 0}}, - selectionRange: %{end: %{character: 0, line: 0}, start: %{character: 0, line: 0}} + name: "MyModule" } ]} = DocumentSymbols.symbols(uri, text, true) end @@ -1386,57 +1368,42 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do [ %Protocol.SymbolInformation{ name: "MyModule", - kind: 2, - location: %{ - range: %{end: %{character: 0, line: 0}, start: %{character: 0, line: 0}} - } + kind: 2 }, %Protocol.SymbolInformation{ - name: "my_callback(type1, type2)", + name: "@callback my_callback(type1, type2)", kind: 24, location: %{ - range: %{end: %{character: 3, line: 1}, start: %{character: 3, line: 1}} + range: %{ + "end" => %{"character" => 52, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} + } }, containerName: "MyModule" }, %Protocol.SymbolInformation{ - name: "my_macrocallback(type1, type2)", + name: "@macrocallback my_macrocallback(type1, type2)", kind: 24, - location: %{ - range: %{end: %{character: 3, line: 2}, start: %{character: 3, line: 2}} - }, containerName: "MyModule" }, %Protocol.SymbolInformation{ - name: "my_callback_when(type1, type2)", + name: "@callback my_callback_when(type1, type2)", kind: 24, - location: %{ - range: %{end: %{character: 3, line: 4}, start: %{character: 3, line: 4}} - }, containerName: "MyModule" }, %Protocol.SymbolInformation{ - name: "my_macrocallback_when(type1, type2)", + name: "@macrocallback my_macrocallback_when(type1, type2)", kind: 24, - location: %{ - range: %{end: %{character: 3, line: 5}, start: %{character: 3, line: 5}} - }, containerName: "MyModule" }, %Protocol.SymbolInformation{ - name: "my_callback_no_arg()", + name: "@callback my_callback_no_arg()", kind: 24, - location: %{ - range: %{end: %{character: 3, line: 7}, start: %{character: 3, line: 7}} - }, containerName: "MyModule" }, %Protocol.SymbolInformation{ - name: "my_macrocallback_no_arg()", + name: "@macrocallback my_macrocallback_no_arg()", kind: 24, - location: %{ - range: %{end: %{character: 3, line: 8}, start: %{character: 3, line: 8}} - }, containerName: "MyModule" } ]} = DocumentSymbols.symbols(uri, text, false) @@ -1455,31 +1422,14 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do [ %Protocol.DocumentSymbol{ children: [ - %Protocol.DocumentSymbol{ - children: [], - kind: 24, - name: "my_fn(integer)", - range: %{end: %{character: 9, line: 2}, start: %{character: 9, line: 2}}, - selectionRange: %{ - end: %{character: 9, line: 2}, - start: %{character: 9, line: 2} - } - }, %Protocol.DocumentSymbol{ children: [], kind: 12, - name: "def my_fn(a)", - range: %{end: %{character: 12, line: 3}, start: %{character: 12, line: 3}}, - selectionRange: %{ - end: %{character: 12, line: 3}, - start: %{character: 12, line: 3} - } + name: "def my_fn(a)" } ], kind: 2, - name: "MyModule", - range: %{end: %{character: 6, line: 1}, start: %{character: 6, line: 1}}, - selectionRange: %{end: %{character: 6, line: 1}, start: %{character: 6, line: 1}} + name: "MyModule" } ]} = DocumentSymbols.symbols(uri, text, true) end @@ -1497,30 +1447,139 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do [ %Protocol.SymbolInformation{ name: "MyModule", - kind: 2, - location: %{ - range: %{end: %{character: 6, line: 1}, start: %{character: 6, line: 1}} - } - }, - %Protocol.SymbolInformation{ - name: "my_fn(integer)", - kind: 24, - location: %{ - range: %{end: %{character: 9, line: 2}, start: %{character: 9, line: 2}} - }, - containerName: "MyModule" + kind: 2 }, %Protocol.SymbolInformation{ name: "def my_fn(a)", kind: 12, - location: %{ - range: %{end: %{character: 12, line: 3}, start: %{character: 12, line: 3}} - }, containerName: "MyModule" } ]} = DocumentSymbols.symbols(uri, text, false) end + test "[nested] handles records" do + uri = "file:///project/file.ex" + text = ~S[ + defmodule MyModule do + require Record + Record.defrecord(:user, name: "meg", age: "25") + end + ] + + result = DocumentSymbols.symbols(uri, text, true) + + # earlier elixir versions return different ranges + if Version.match?(System.version(), ">= 1.13.0") do + assert {:ok, + [ + %Protocol.DocumentSymbol{ + children: [ + %Protocol.DocumentSymbol{ + children: [ + %Protocol.DocumentSymbol{ + children: [], + kind: 7, + name: "name", + range: %{ + "end" => %{"character" => 55, "line" => 3}, + "start" => %{"character" => 15, "line" => 3} + }, + selectionRange: %{ + "end" => %{"character" => 55, "line" => 3}, + "start" => %{"character" => 15, "line" => 3} + } + }, + %Protocol.DocumentSymbol{ + children: [], + kind: 7, + name: "age", + range: %{ + "end" => %{"character" => 55, "line" => 3}, + "start" => %{"character" => 15, "line" => 3} + }, + selectionRange: %{ + "end" => %{"character" => 55, "line" => 3}, + "start" => %{"character" => 15, "line" => 3} + } + } + ], + kind: 5, + name: "defrecord :user", + range: %{ + "end" => %{"character" => 55, "line" => 3}, + "start" => %{"character" => 8, "line" => 3} + }, + selectionRange: %{ + "end" => %{"character" => 55, "line" => 3}, + "start" => %{"character" => 8, "line" => 3} + } + } + ], + kind: 2, + name: "MyModule" + } + ]} = result + end + end + + test "[flat] handles records" do + uri = "file:///project/file.ex" + text = ~S[ + defmodule MyModule do + require Record + Record.defrecord(:user, name: "meg", age: "25") + end + ] + + result = DocumentSymbols.symbols(uri, text, false) + + # earlier elixir versions return different ranges + if Version.match?(System.version(), ">= 1.13.0") do + assert {:ok, + [ + %Protocol.SymbolInformation{ + name: "MyModule", + kind: 2, + location: %{ + range: %{ + "end" => %{"character" => 9, "line" => 4}, + "start" => %{"character" => 6, "line" => 1} + } + } + }, + %Protocol.SymbolInformation{ + name: "defrecord :user", + kind: 5, + containerName: "MyModule" + }, + %Protocol.SymbolInformation{ + containerName: "defrecord :user", + kind: 7, + location: %{ + range: %{ + "end" => %{"character" => 55, "line" => 3}, + "start" => %{"character" => 15, "line" => 3} + }, + uri: "file:///project/file.ex" + }, + name: "name" + }, + %Protocol.SymbolInformation{ + containerName: "defrecord :user", + kind: 7, + location: %{ + range: %{ + "end" => %{"character" => 55, "line" => 3}, + "start" => %{"character" => 15, "line" => 3} + }, + uri: "file:///project/file.ex" + }, + name: "age" + } + ]} = result + end + end + test "[nested] skips docs attributes" do uri = "file:///project/file.ex" @@ -1537,9 +1596,7 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do %Protocol.DocumentSymbol{ children: [], kind: 2, - name: "MyModule", - range: %{end: %{character: 0, line: 0}, start: %{character: 0, line: 0}}, - selectionRange: %{end: %{character: 0, line: 0}, start: %{character: 0, line: 0}} + name: "MyModule" } ]} = DocumentSymbols.symbols(uri, text, true) end @@ -1559,10 +1616,7 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do [ %Protocol.SymbolInformation{ name: "MyModule", - kind: 2, - location: %{ - range: %{end: %{character: 0, line: 0}, start: %{character: 0, line: 0}} - } + kind: 2 } ]} = DocumentSymbols.symbols(uri, text, false) end @@ -1600,177 +1654,83 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do children: [], kind: 14, name: "@optional_callbacks", - range: %{end: %{character: 3, line: 1}, start: %{character: 3, line: 1}}, - selectionRange: %{ - end: %{character: 3, line: 1}, - start: %{character: 3, line: 1} - } - }, - %Protocol.DocumentSymbol{ - children: [], - kind: 14, - name: "@behaviour MyBehaviour", - range: %{end: %{character: 3, line: 2}, start: %{character: 3, line: 2}}, - selectionRange: %{ - end: %{character: 3, line: 2}, - start: %{character: 3, line: 2} - } - }, - %Protocol.DocumentSymbol{ - children: [], - kind: 14, - name: "@impl true", - range: %{end: %{character: 3, line: 3}, start: %{character: 3, line: 3}}, - selectionRange: %{ - end: %{character: 3, line: 3}, - start: %{character: 3, line: 3} - } - }, - %Protocol.DocumentSymbol{ - children: [], - kind: 14, - name: "@derive", - range: %{end: %{character: 3, line: 4}, start: %{character: 3, line: 4}}, + range: %{ + "end" => %{"character" => 58, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} + }, selectionRange: %{ - end: %{character: 3, line: 4}, - start: %{character: 3, line: 4} + "end" => %{"character" => 58, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} } }, %Protocol.DocumentSymbol{ children: [], - kind: 14, - name: "@enforce_keys", - range: %{end: %{character: 3, line: 5}, start: %{character: 3, line: 5}}, - selectionRange: %{ - end: %{character: 3, line: 5}, - start: %{character: 3, line: 5} - } + kind: 11, + name: "@behaviour MyBehaviour" }, %Protocol.DocumentSymbol{ children: [], kind: 14, - name: "@compile", - range: %{end: %{character: 3, line: 6}, start: %{character: 3, line: 6}}, - selectionRange: %{ - end: %{character: 3, line: 6}, - start: %{character: 3, line: 6} - } + name: "@derive" }, %Protocol.DocumentSymbol{ children: [], kind: 14, - name: "@deprecated", - range: %{end: %{character: 3, line: 7}, start: %{character: 3, line: 7}}, - selectionRange: %{ - end: %{character: 3, line: 7}, - start: %{character: 3, line: 7} - } + name: "@enforce_keys" }, %Protocol.DocumentSymbol{ children: [], kind: 14, - name: "@dialyzer", - range: %{end: %{character: 3, line: 8}, start: %{character: 3, line: 8}}, - selectionRange: %{ - end: %{character: 3, line: 8}, - start: %{character: 3, line: 8} - } + name: "@compile" }, %Protocol.DocumentSymbol{ children: [], kind: 14, - name: "@file", - range: %{end: %{character: 3, line: 9}, start: %{character: 3, line: 9}}, - selectionRange: %{ - end: %{character: 3, line: 9}, - start: %{character: 3, line: 9} - } + name: "@dialyzer" }, %Protocol.DocumentSymbol{ children: [], kind: 14, - name: "@external_resource", - range: %{end: %{character: 3, line: 10}, start: %{character: 3, line: 10}}, - selectionRange: %{ - end: %{character: 3, line: 10}, - start: %{character: 3, line: 10} - } + name: "@file" }, %Protocol.DocumentSymbol{ children: [], kind: 14, - name: "@on_load", - range: %{end: %{character: 3, line: 11}, start: %{character: 3, line: 11}}, - selectionRange: %{ - end: %{character: 3, line: 11}, - start: %{character: 3, line: 11} - } + name: "@external_resource" }, %Protocol.DocumentSymbol{ children: [], kind: 14, - name: "@on_definition", - range: %{end: %{character: 3, line: 12}, start: %{character: 3, line: 12}}, - selectionRange: %{ - end: %{character: 3, line: 12}, - start: %{character: 3, line: 12} - } + name: "@on_load" }, %Protocol.DocumentSymbol{ children: [], kind: 14, - name: "@vsn", - range: %{end: %{character: 3, line: 13}, start: %{character: 3, line: 13}}, - selectionRange: %{ - end: %{character: 3, line: 13}, - start: %{character: 3, line: 13} - } + name: "@on_definition" }, %Protocol.DocumentSymbol{ children: [], kind: 14, - name: "@after_compile", - range: %{end: %{character: 3, line: 14}, start: %{character: 3, line: 14}}, - selectionRange: %{ - end: %{character: 3, line: 14}, - start: %{character: 3, line: 14} - } + name: "@vsn" }, %Protocol.DocumentSymbol{ children: [], kind: 14, - name: "@before_compile", - range: %{end: %{character: 3, line: 15}, start: %{character: 3, line: 15}}, - selectionRange: %{ - end: %{character: 3, line: 15}, - start: %{character: 3, line: 15} - } + name: "@after_compile" }, %Protocol.DocumentSymbol{ children: [], kind: 14, - name: "@fallback_to_any", - range: %{end: %{character: 3, line: 16}, start: %{character: 3, line: 16}}, - selectionRange: %{ - end: %{character: 3, line: 16}, - start: %{character: 3, line: 16} - } + name: "@before_compile" }, %Protocol.DocumentSymbol{ children: [], kind: 14, - name: "@impl MyBehaviour", - range: %{end: %{character: 3, line: 17}, start: %{character: 3, line: 17}}, - selectionRange: %{ - end: %{character: 3, line: 17}, - start: %{character: 3, line: 17} - } + name: "@fallback_to_any" } ], kind: 2, - name: "MyModule", - range: %{end: %{character: 0, line: 0}, start: %{character: 0, line: 0}}, - selectionRange: %{end: %{character: 0, line: 0}, start: %{character: 0, line: 0}} + name: "MyModule" } ]} = DocumentSymbols.symbols(uri, text, true) end @@ -1806,143 +1766,86 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do name: "MyModule", kind: 2, location: %{ - range: %{end: %{character: 0, line: 0}, start: %{character: 0, line: 0}} + range: %{ + "end" => %{"character" => 3, "line" => 18}, + "start" => %{"character" => 0, "line" => 0} + } } }, %Protocol.SymbolInformation{ name: "@optional_callbacks", kind: 14, location: %{ - range: %{end: %{character: 3, line: 1}, start: %{character: 3, line: 1}} + range: %{ + "end" => %{"character" => 58, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} + } }, containerName: "MyModule" }, %Protocol.SymbolInformation{ name: "@behaviour MyBehaviour", - kind: 14, - location: %{ - range: %{end: %{character: 3, line: 2}, start: %{character: 3, line: 2}} - }, - containerName: "MyModule" - }, - %Protocol.SymbolInformation{ - name: "@impl true", - kind: 14, - location: %{ - range: %{end: %{character: 3, line: 3}, start: %{character: 3, line: 3}} - }, + kind: 11, containerName: "MyModule" }, %Protocol.SymbolInformation{ name: "@derive", kind: 14, - location: %{ - range: %{end: %{character: 3, line: 4}, start: %{character: 3, line: 4}} - }, containerName: "MyModule" }, %Protocol.SymbolInformation{ name: "@enforce_keys", kind: 14, - location: %{ - range: %{end: %{character: 3, line: 5}, start: %{character: 3, line: 5}} - }, containerName: "MyModule" }, %Protocol.SymbolInformation{ name: "@compile", kind: 14, - location: %{ - range: %{end: %{character: 3, line: 6}, start: %{character: 3, line: 6}} - }, - containerName: "MyModule" - }, - %Protocol.SymbolInformation{ - name: "@deprecated", - kind: 14, - location: %{ - range: %{end: %{character: 3, line: 7}, start: %{character: 3, line: 7}} - }, containerName: "MyModule" }, %Protocol.SymbolInformation{ name: "@dialyzer", kind: 14, - location: %{ - range: %{end: %{character: 3, line: 8}, start: %{character: 3, line: 8}} - }, containerName: "MyModule" }, %Protocol.SymbolInformation{ name: "@file", kind: 14, - location: %{ - range: %{end: %{character: 3, line: 9}, start: %{character: 3, line: 9}} - }, containerName: "MyModule" }, %Protocol.SymbolInformation{ name: "@external_resource", kind: 14, - location: %{ - range: %{end: %{character: 3, line: 10}, start: %{character: 3, line: 10}} - }, containerName: "MyModule" }, %Protocol.SymbolInformation{ name: "@on_load", kind: 14, - location: %{ - range: %{end: %{character: 3, line: 11}, start: %{character: 3, line: 11}} - }, containerName: "MyModule" }, %Protocol.SymbolInformation{ name: "@on_definition", kind: 14, - location: %{ - range: %{end: %{character: 3, line: 12}, start: %{character: 3, line: 12}} - }, containerName: "MyModule" }, %Protocol.SymbolInformation{ name: "@vsn", kind: 14, - location: %{ - range: %{end: %{character: 3, line: 13}, start: %{character: 3, line: 13}} - }, containerName: "MyModule" }, %Protocol.SymbolInformation{ name: "@after_compile", kind: 14, - location: %{ - range: %{end: %{character: 3, line: 14}, start: %{character: 3, line: 14}} - }, containerName: "MyModule" }, %Protocol.SymbolInformation{ name: "@before_compile", kind: 14, - location: %{ - range: %{end: %{character: 3, line: 15}, start: %{character: 3, line: 15}} - }, containerName: "MyModule" }, %Protocol.SymbolInformation{ name: "@fallback_to_any", kind: 14, - location: %{ - range: %{end: %{character: 3, line: 16}, start: %{character: 3, line: 16}} - }, - containerName: "MyModule" - }, - %Protocol.SymbolInformation{ - name: "@impl MyBehaviour", - kind: 14, - location: %{ - range: %{end: %{character: 3, line: 17}, start: %{character: 3, line: 17}} - }, containerName: "MyModule" } ]} = DocumentSymbols.symbols(uri, text, false) @@ -1966,25 +1869,17 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do kind: 12, name: "test \"does something\"", range: %{ - end: %{character: 8, line: 3}, - start: %{character: 8, line: 3} + "end" => %{"character" => 8, "line" => 3}, + "start" => %{"character" => 8, "line" => 3} }, selectionRange: %{ - end: %{character: 8, line: 3}, - start: %{character: 8, line: 3} + "end" => %{"character" => 8, "line" => 3}, + "start" => %{"character" => 8, "line" => 3} } } ], kind: 2, - name: "MyModuleTest", - range: %{ - end: %{character: 6, line: 1}, - start: %{character: 6, line: 1} - }, - selectionRange: %{ - end: %{character: 6, line: 1}, - start: %{character: 6, line: 1} - } + name: "MyModuleTest" } ]} = DocumentSymbols.symbols(uri, text, true) end @@ -2004,14 +1899,20 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do name: "MyModuleTest", kind: 2, location: %{ - range: %{end: %{character: 6, line: 1}, start: %{character: 6, line: 1}} + range: %{ + "end" => %{"character" => 9, "line" => 4}, + "start" => %{"character" => 6, "line" => 1} + } } }, %Protocol.SymbolInformation{ name: "test \"does something\"", kind: 12, location: %{ - range: %{end: %{character: 8, line: 3}, start: %{character: 8, line: 3}} + range: %{ + "end" => %{"character" => 8, "line" => 3}, + "start" => %{"character" => 8, "line" => 3} + } }, containerName: "MyModuleTest" } @@ -2040,37 +1941,29 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do kind: 12, name: "test \"does something\"", range: %{ - end: %{character: 10, line: 4}, - start: %{character: 10, line: 4} + "end" => %{"character" => 10, "line" => 4}, + "start" => %{"character" => 10, "line" => 4} }, selectionRange: %{ - end: %{character: 10, line: 4}, - start: %{character: 10, line: 4} + "end" => %{"character" => 10, "line" => 4}, + "start" => %{"character" => 10, "line" => 4} } } ], kind: 12, name: "describe \"some description\"", range: %{ - end: %{character: 8, line: 3}, - start: %{character: 8, line: 3} + "end" => %{"character" => 11, "line" => 5}, + "start" => %{"character" => 8, "line" => 3} }, selectionRange: %{ - end: %{character: 8, line: 3}, - start: %{character: 8, line: 3} + "end" => %{"character" => 11, "line" => 5}, + "start" => %{"character" => 8, "line" => 3} } } ], kind: 2, - name: "MyModuleTest", - range: %{ - end: %{character: 6, line: 1}, - start: %{character: 6, line: 1} - }, - selectionRange: %{ - end: %{character: 6, line: 1}, - start: %{character: 6, line: 1} - } + name: "MyModuleTest" } ]} = DocumentSymbols.symbols(uri, text, true) end @@ -2097,45 +1990,33 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do kind: 12, name: "test \"does\" <> \"something\"", range: %{ - end: %{character: 10, line: 4}, - start: %{character: 10, line: 4} + "end" => %{"character" => 10, "line" => 4}, + "start" => %{"character" => 10, "line" => 4} }, selectionRange: %{ - end: %{character: 10, line: 4}, - start: %{character: 10, line: 4} + "end" => %{"character" => 10, "line" => 4}, + "start" => %{"character" => 10, "line" => 4} } } ], kind: 12, name: describe_sigil, range: %{ - end: %{character: 8, line: 3}, - start: %{character: 8, line: 3} + "end" => %{"character" => 11, "line" => 5}, + "start" => %{"character" => 8, "line" => 3} }, selectionRange: %{ - end: %{character: 8, line: 3}, - start: %{character: 8, line: 3} + "end" => %{"character" => 11, "line" => 5}, + "start" => %{"character" => 8, "line" => 3} } } ], kind: 2, - name: "MyModuleTest", - range: %{ - end: %{character: 6, line: 1}, - start: %{character: 6, line: 1} - }, - selectionRange: %{ - end: %{character: 6, line: 1}, - start: %{character: 6, line: 1} - } + name: "MyModuleTest" } ]} = DocumentSymbols.symbols(uri, text, true) - if System.version() |> Version.match?(">= 1.10.0") do - assert describe_sigil == "describe ~S(some \"description\")" - else - assert describe_sigil == "describe ~S'some \"description\"'" - end + assert describe_sigil == "describe ~S(some \"description\")" end test "[flat] handles exunit describe tests" do @@ -2155,14 +2036,20 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do name: "MyModuleTest", kind: 2, location: %{ - range: %{end: %{character: 6, line: 1}, start: %{character: 6, line: 1}} + range: %{ + "end" => %{"character" => 9, "line" => 6}, + "start" => %{"character" => 6, "line" => 1} + } } }, %Protocol.SymbolInformation{ name: "describe \"some description\"", kind: 12, location: %{ - range: %{end: %{character: 8, line: 3}, start: %{character: 8, line: 3}} + range: %{ + "end" => %{"character" => 11, "line" => 5}, + "start" => %{"character" => 8, "line" => 3} + } }, containerName: "MyModuleTest" }, @@ -2170,7 +2057,10 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do name: "test \"does something\"", kind: 12, location: %{ - range: %{end: %{character: 10, line: 4}, start: %{character: 10, line: 4}} + range: %{ + "end" => %{"character" => 10, "line" => 4}, + "start" => %{"character" => 10, "line" => 4} + } }, containerName: "describe \"some description\"" } @@ -2194,14 +2084,20 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do name: "MyModuleTest", kind: 2, location: %{ - range: %{end: %{character: 6, line: 1}, start: %{character: 6, line: 1}} + range: %{ + "end" => %{"character" => 9, "line" => 6}, + "start" => %{"character" => 6, "line" => 1} + } } }, %Protocol.SymbolInformation{ name: describe_sigil, kind: 12, location: %{ - range: %{end: %{character: 8, line: 3}, start: %{character: 8, line: 3}} + range: %{ + "end" => %{"character" => 11, "line" => 5}, + "start" => %{"character" => 8, "line" => 3} + } }, containerName: "MyModuleTest" }, @@ -2209,17 +2105,16 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do name: "test \"does\" <> \"something\"", kind: 12, location: %{ - range: %{end: %{character: 10, line: 4}, start: %{character: 10, line: 4}} + range: %{ + "end" => %{"character" => 10, "line" => 4}, + "start" => %{"character" => 10, "line" => 4} + } }, containerName: describe_sigil } ]} = DocumentSymbols.symbols(uri, text, false) - if System.version() |> Version.match?(">= 1.10.0") do - assert describe_sigil == "describe ~S(some \"description\")" - else - assert describe_sigil == "describe ~S'some \"description\"'" - end + assert describe_sigil == "describe ~S(some \"description\")" end test "[nested] handles exunit callbacks" do @@ -2246,37 +2141,44 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do children: [], kind: 12, name: "setup", - range: %{end: %{character: 2, line: 2}, start: %{character: 2, line: 2}}, + range: %{ + "end" => %{"character" => 5, "line" => 4}, + "start" => %{"character" => 2, "line" => 2} + }, selectionRange: %{ - end: %{character: 2, line: 2}, - start: %{character: 2, line: 2} + "end" => %{"character" => 5, "line" => 4}, + "start" => %{"character" => 2, "line" => 2} } }, %Protocol.DocumentSymbol{ children: [], kind: 12, name: "setup", - range: %{end: %{character: 2, line: 5}, start: %{character: 2, line: 5}}, + range: %{ + "end" => %{"character" => 31, "line" => 5}, + "start" => %{"character" => 2, "line" => 5} + }, selectionRange: %{ - end: %{character: 2, line: 5}, - start: %{character: 2, line: 5} + "end" => %{"character" => 31, "line" => 5}, + "start" => %{"character" => 2, "line" => 5} } }, %Protocol.DocumentSymbol{ children: [], kind: 12, name: "setup_all", - range: %{end: %{character: 2, line: 6}, start: %{character: 2, line: 6}}, + range: %{ + "end" => %{"character" => 5, "line" => 8}, + "start" => %{"character" => 2, "line" => 6} + }, selectionRange: %{ - end: %{character: 2, line: 6}, - start: %{character: 2, line: 6} + "end" => %{"character" => 5, "line" => 8}, + "start" => %{"character" => 2, "line" => 6} } } ], kind: 2, - name: "MyModuleTest", - range: %{end: %{character: 0, line: 0}, start: %{character: 0, line: 0}}, - selectionRange: %{end: %{character: 0, line: 0}, start: %{character: 0, line: 0}} + name: "MyModuleTest" } ]} = DocumentSymbols.symbols(uri, text, true) end @@ -2303,14 +2205,20 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do name: "MyModuleTest", kind: 2, location: %{ - range: %{end: %{character: 0, line: 0}, start: %{character: 0, line: 0}} + range: %{ + "end" => %{"character" => 3, "line" => 9}, + "start" => %{"character" => 0, "line" => 0} + } } }, %Protocol.SymbolInformation{ name: "setup", kind: 12, location: %{ - range: %{end: %{character: 2, line: 2}, start: %{character: 2, line: 2}} + range: %{ + "end" => %{"character" => 5, "line" => 4}, + "start" => %{"character" => 2, "line" => 2} + } }, containerName: "MyModuleTest" }, @@ -2318,7 +2226,10 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do name: "setup", kind: 12, location: %{ - range: %{end: %{character: 2, line: 5}, start: %{character: 2, line: 5}} + range: %{ + "end" => %{"character" => 31, "line" => 5}, + "start" => %{"character" => 2, "line" => 5} + } }, containerName: "MyModuleTest" }, @@ -2326,7 +2237,10 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do name: "setup_all", kind: 12, location: %{ - range: %{end: %{character: 2, line: 6}, start: %{character: 2, line: 6}} + range: %{ + "end" => %{"character" => 5, "line" => 8}, + "start" => %{"character" => 2, "line" => 6} + } }, containerName: "MyModuleTest" } @@ -2356,29 +2270,53 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do children: [], kind: 20, name: "config :logger :console", - range: %{end: %{character: 0, line: 1}, start: %{character: 0, line: 1}}, - selectionRange: %{end: %{character: 0, line: 1}, start: %{character: 0, line: 1}} + range: %{ + "end" => %{"character" => 23, "line" => 5}, + "start" => %{"character" => 0, "line" => 1} + }, + selectionRange: %{ + "end" => %{"character" => 23, "line" => 5}, + "start" => %{"character" => 0, "line" => 1} + } }, %Protocol.DocumentSymbol{ children: [], kind: 20, name: "config :app :key", - range: %{end: %{character: 0, line: 6}, start: %{character: 0, line: 6}}, - selectionRange: %{end: %{character: 0, line: 6}, start: %{character: 0, line: 6}} + range: %{ + "end" => %{"character" => 25, "line" => 6}, + "start" => %{"character" => 0, "line" => 6} + }, + selectionRange: %{ + "end" => %{"character" => 25, "line" => 6}, + "start" => %{"character" => 0, "line" => 6} + } }, %Protocol.DocumentSymbol{ children: [], kind: 20, - name: "config :my_app :ecto_repos", - range: %{end: %{character: 0, line: 7}, start: %{character: 0, line: 7}}, - selectionRange: %{end: %{character: 0, line: 7}, start: %{character: 0, line: 7}} + name: "config :my_app [:ecto_repos]", + range: %{ + "end" => %{"character" => 26, "line" => 8}, + "start" => %{"character" => 0, "line" => 7} + }, + selectionRange: %{ + "end" => %{"character" => 26, "line" => 8}, + "start" => %{"character" => 0, "line" => 7} + } }, %Protocol.DocumentSymbol{ children: [], kind: 20, name: "config :my_app MyApp.Repo", - range: %{end: %{character: 0, line: 9}, start: %{character: 0, line: 9}}, - selectionRange: %{end: %{character: 0, line: 9}, start: %{character: 0, line: 9}} + range: %{ + "end" => %{"character" => 0, "line" => 9}, + "start" => %{"character" => 0, "line" => 9} + }, + selectionRange: %{ + "end" => %{"character" => 0, "line" => 9}, + "start" => %{"character" => 0, "line" => 9} + } } ]} = DocumentSymbols.symbols(uri, text, true) end @@ -2406,28 +2344,40 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do name: "config :logger :console", kind: 20, location: %{ - range: %{end: %{character: 0, line: 1}, start: %{character: 0, line: 1}} + range: %{ + "end" => %{"character" => 23, "line" => 5}, + "start" => %{"character" => 0, "line" => 1} + } } }, %Protocol.SymbolInformation{ name: "config :app :key", kind: 20, location: %{ - range: %{end: %{character: 0, line: 6}, start: %{character: 0, line: 6}} + range: %{ + "end" => %{"character" => 25, "line" => 6}, + "start" => %{"character" => 0, "line" => 6} + } } }, %Protocol.SymbolInformation{ - name: "config :my_app :ecto_repos", + name: "config :my_app [:ecto_repos]", kind: 20, location: %{ - range: %{end: %{character: 0, line: 7}, start: %{character: 0, line: 7}} + range: %{ + "end" => %{"character" => 26, "line" => 8}, + "start" => %{"character" => 0, "line" => 7} + } } }, %Protocol.SymbolInformation{ name: "config :my_app MyApp.Repo", kind: 20, location: %{ - range: %{end: %{character: 0, line: 9}, start: %{character: 0, line: 9}} + range: %{ + "end" => %{"character" => 0, "line" => 9}, + "start" => %{"character" => 0, "line" => 9} + } } } ]} = DocumentSymbols.symbols(uri, text, false) @@ -2448,33 +2398,17 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do %Protocol.DocumentSymbol{ children: children, kind: 2, - name: "MISSING_MODULE_NAME", - range: %{ - start: %{line: 0, character: 0}, - end: %{line: 0, character: 0} - }, - selectionRange: %{ - start: %{line: 0, character: 0}, - end: %{line: 0, character: 0} - } + name: "MISSING_MODULE_NAME" } ] = document_symbols - assert children == [ + assert [ %Protocol.DocumentSymbol{ children: [], kind: 12, - name: "def foo", - range: %{ - start: %{character: 4, line: 1}, - end: %{character: 4, line: 1} - }, - selectionRange: %{ - start: %{character: 4, line: 1}, - end: %{character: 4, line: 1} - } + name: "def foo" } - ] + ] = children end test "[nested] handles a file with a top-level protocol module without a name" do @@ -2487,21 +2421,13 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do assert {:ok, document_symbols} = DocumentSymbols.symbols(uri, text, true) - assert document_symbols == [ + assert [ %Protocol.DocumentSymbol{ children: [], kind: 11, - name: "MISSING_PROTOCOL_NAME", - range: %{ - start: %{line: 0, character: 0}, - end: %{line: 0, character: 0} - }, - selectionRange: %{ - start: %{line: 0, character: 0}, - end: %{line: 0, character: 0} - } + name: "MISSING_PROTOCOL_NAME" } - ] + ] = document_symbols end test "handles a file with compilation errors by returning an empty list" do @@ -2548,4 +2474,24 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do } ]} = DocumentSymbols.symbols(uri, text, true) end + + describe "invalid documents" do + test "handles a module being defined" do + uri = "file:///project.test.ex" + text = "defmodule " + assert {:ok, []} = DocumentSymbols.symbols(uri, text, true) + end + + test "handles a protocol being defined" do + uri = "file:///project.test.ex" + text = "defprotocol " + assert {:ok, []} = DocumentSymbols.symbols(uri, text, true) + end + + test "handles a protocol being impolemented" do + uri = "file:///project.test.ex" + text = "defimpl " + assert {:ok, []} = DocumentSymbols.symbols(uri, text, true) + end + end end diff --git a/apps/language_server/test/providers/execute_command/get_ex_unit_tests_in_file_test.exs b/apps/language_server/test/providers/execute_command/get_ex_unit_tests_in_file_test.exs new file mode 100644 index 000000000..688cc9f6a --- /dev/null +++ b/apps/language_server/test/providers/execute_command/get_ex_unit_tests_in_file_test.exs @@ -0,0 +1,55 @@ +defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.GetExUnitTestsInFileTest do + alias ElixirLS.LanguageServer.{ExUnitTestTracer, SourceFile} + alias ElixirLS.LanguageServer.Providers.ExecuteCommand.GetExUnitTestsInFile + use ElixirLS.Utils.MixTest.Case, async: false + + setup do + {:ok, _} = start_supervised(ExUnitTestTracer) + + {:ok, %{}} + end + + if Version.match?(System.version(), ">= 1.13.0") do + @tag fixture: true + test "return tests" do + in_fixture(Path.join(__DIR__, "../../../test_fixtures"), "project_with_tests", fn -> + uri = SourceFile.Path.to_uri(Path.join(File.cwd!(), "test/fixture_test.exs")) + + assert {:ok, + [ + %{ + describes: [ + %{ + describe: nil, + line: nil, + tests: [ + %{line: 19, name: "this will be a test in future", type: :test}, + %{line: 6, name: "fixture test", type: :test} + ] + }, + %{ + describe: "describe with test", + line: 10, + tests: [ + %{line: 11, name: "fixture test", type: :test} + ] + } + ], + line: 0, + module: "FixtureTest" + } + ]} = GetExUnitTestsInFile.execute([uri], nil) + end) + end + + @tag fixture: true + test "return error when file fails to compile" do + in_fixture(Path.join(__DIR__, "../../../test_fixtures"), "project_with_tests", fn -> + uri = SourceFile.Path.to_uri(Path.join(File.cwd!(), "test/error_test.exs")) + + assert {:error, :server_error, "%TokenMissingError" <> _} = + GetExUnitTestsInFile.execute([uri], nil) + end) + end + end +end diff --git a/apps/language_server/test/providers/execute_command/mix_clean_test.exs b/apps/language_server/test/providers/execute_command/mix_clean_test.exs new file mode 100644 index 000000000..cbda272f4 --- /dev/null +++ b/apps/language_server/test/providers/execute_command/mix_clean_test.exs @@ -0,0 +1,48 @@ +defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.MixCleanTest do + alias ElixirLS.LanguageServer.{Server, Protocol, SourceFile, Tracer} + use ElixirLS.Utils.MixTest.Case, async: false + use Protocol + + setup do + {:ok, _} = start_supervised(Tracer) + server = ElixirLS.LanguageServer.Test.ServerTestHelpers.start_server() + + {:ok, %{server: server}} + end + + @tag fixture: true + test "mix clean", %{server: server} do + in_fixture(Path.join(__DIR__, "../.."), "clean", fn -> + root_uri = SourceFile.Path.to_uri(File.cwd!()) + Server.receive_packet(server, initialize_req(1, root_uri, %{})) + + Server.receive_packet( + server, + did_change_configuration(%{ + "elixirLS" => %{"dialyzerEnabled" => false} + }) + ) + + assert_receive %{ + "method" => "window/logMessage", + "params" => %{"message" => "Compile took" <> _} + }, + 20000 + + path = ".elixir_ls/build/test/lib/els_clean_test/ebin/Elixir.A.beam" + assert File.exists?(path) + + server_instance_id = :sys.get_state(server).server_instance_id + + Server.receive_packet( + server, + execute_command_req(4, "mixClean:#{server_instance_id}", [false]) + ) + + res = assert_receive(%{"id" => 4}, 5000) + assert res["result"] == %{} + + refute File.exists?(path) + end) + end +end diff --git a/apps/language_server/test/providers/formatting_test.exs b/apps/language_server/test/providers/formatting_test.exs index 778a1a504..7fd4c7664 100644 --- a/apps/language_server/test/providers/formatting_test.exs +++ b/apps/language_server/test/providers/formatting_test.exs @@ -10,7 +10,7 @@ defmodule ElixirLS.LanguageServer.Providers.FormattingTest do test "no mixfile" do in_fixture(Path.join(__DIR__, ".."), "no_mixfile", fn -> path = "lib/file.ex" - uri = SourceFile.path_to_uri(path) + uri = SourceFile.Path.to_uri(path) text = """ defmodule MyModule do @@ -60,7 +60,7 @@ defmodule ElixirLS.LanguageServer.Providers.FormattingTest do test "no project dir" do in_fixture(Path.join(__DIR__, ".."), "no_mixfile", fn -> path = "lib/file.ex" - uri = SourceFile.path_to_uri(path) + uri = SourceFile.Path.to_uri(path) text = """ defmodule MyModule do @@ -110,7 +110,7 @@ defmodule ElixirLS.LanguageServer.Providers.FormattingTest do test "Formats a file with LF line endings" do in_fixture(Path.join(__DIR__, ".."), "formatter", fn -> path = "lib/file.ex" - uri = SourceFile.path_to_uri(path) + uri = SourceFile.Path.to_uri(path) text = """ defmodule MyModule do @@ -160,7 +160,7 @@ defmodule ElixirLS.LanguageServer.Providers.FormattingTest do test "Formats a file with CRLF line endings" do in_fixture(Path.join(__DIR__, ".."), "formatter", fn -> path = "lib/file.ex" - uri = SourceFile.path_to_uri(path) + uri = SourceFile.Path.to_uri(path) text = """ defmodule MyModule do @@ -247,7 +247,7 @@ defmodule ElixirLS.LanguageServer.Providers.FormattingTest do test "elixir formatter does not support CR line endings" do in_fixture(Path.join(__DIR__, ".."), "formatter", fn -> path = "lib/file.ex" - uri = SourceFile.path_to_uri(path) + uri = SourceFile.Path.to_uri(path) text = """ defmodule MyModule do @@ -278,7 +278,7 @@ defmodule ElixirLS.LanguageServer.Providers.FormattingTest do test "formatting preserves line indings inside a string" do in_fixture(Path.join(__DIR__, ".."), "formatter", fn -> path = "lib/file.ex" - uri = SourceFile.path_to_uri(path) + uri = SourceFile.Path.to_uri(path) text = """ defmodule MyModule do @@ -333,7 +333,7 @@ defmodule ElixirLS.LanguageServer.Providers.FormattingTest do test "returns an error when formatting a file with a syntax error" do in_fixture(Path.join(__DIR__, ".."), "formatter", fn -> path = "lib/file.ex" - uri = SourceFile.path_to_uri(path) + uri = SourceFile.Path.to_uri(path) text = """ defmodule MyModule do @@ -362,7 +362,7 @@ defmodule ElixirLS.LanguageServer.Providers.FormattingTest do test "Proper utf-16 format: emoji 😀" do in_fixture(Path.join(__DIR__, ".."), "formatter", fn -> path = "lib/file.ex" - uri = SourceFile.path_to_uri(path) + uri = SourceFile.Path.to_uri(path) text = """ IO.puts "😀" @@ -401,7 +401,7 @@ defmodule ElixirLS.LanguageServer.Providers.FormattingTest do test "Proper utf-16 format: emoji 🏳️‍🌈" do in_fixture(Path.join(__DIR__, ".."), "formatter", fn -> path = "lib/file.ex" - uri = SourceFile.path_to_uri(path) + uri = SourceFile.Path.to_uri(path) text = """ IO.puts "🏳️‍🌈" @@ -440,7 +440,7 @@ defmodule ElixirLS.LanguageServer.Providers.FormattingTest do test "Proper utf-16 format: zalgo" do in_fixture(Path.join(__DIR__, ".."), "formatter", fn -> path = "lib/file.ex" - uri = SourceFile.path_to_uri(path) + uri = SourceFile.Path.to_uri(path) text = """ IO.puts "ẕ̸͇̞̲͇͕̹̙̄͆̇͂̏̊͒̒̈́́̕͘͠͝à̵̢̛̟̞͚̟͖̻̹̮̘͚̻͍̇͂̂̅́̎̉͗́́̃̒l̴̻̳͉̖̗͖̰̠̗̃̈́̓̓̍̅͝͝͝g̷̢͚̠̜̿̊́̋͗̔ȍ̶̹̙̅̽̌̒͌͋̓̈́͑̏͑͊͛͘ ̸̨͙̦̫̪͓̠̺̫̖͙̫̏͂̒̽́̿̂̊́͂͋͜͠͝͝ṭ̴̜͎̮͉̙͍͔̜̾͋͒̓̏̉̄͘͠͝ͅę̷̡̭̹̰̺̩̠͓͌̃̕͜͝ͅͅx̵̧͍̦͈͍̝͖͙̘͎̥͕̾̾̍̀̿̔̄̑̈͝t̸̛͇̀̕" @@ -500,7 +500,11 @@ defmodule ElixirLS.LanguageServer.Providers.FormattingTest do end def assert_formatted(path, project_dir) do - assert match?({:ok, [%{}]}, format(path, project_dir)), "expected '#{path}' to be formatted" + assert match?( + {:ok, [%ElixirLS.LanguageServer.Protocol.TextEdit{} | _]}, + format(path, project_dir) + ), + "expected '#{path}' to be formatted" end def refute_formatted(path, project_dir) do @@ -512,12 +516,12 @@ defmodule ElixirLS.LanguageServer.Providers.FormattingTest do path = maybe_convert_path_separators("#{project_dir}/#{path}") source_file = %SourceFile{ - text: "", + text: " asd = 1", version: 1, dirty?: true } - File.write!(path, "") - Formatting.format(source_file, SourceFile.path_to_uri(path), project_dir) + File.write!(path, " asd = 1") + Formatting.format(source_file, SourceFile.Path.to_uri(path), project_dir) end end diff --git a/apps/language_server/test/providers/hover_test.exs b/apps/language_server/test/providers/hover_test.exs index cc1aa967d..3399ed090 100644 --- a/apps/language_server/test/providers/hover_test.exs +++ b/apps/language_server/test/providers/hover_test.exs @@ -101,6 +101,50 @@ defmodule ElixirLS.LanguageServer.Providers.HoverTest do ) end + test "Import function hover" do + text = """ + defmodule MyModule do + import Task.Supervisor + + def hello() do + start_link() + end + end + """ + + {line, char} = {4, 5} + + assert {:ok, %{"contents" => %{kind: "markdown", value: v}}} = + Hover.hover(text, line, char, fake_dir()) + + assert String.starts_with?( + v, + "> Task.Supervisor.start_link(options \\\\\\\\ []) [view on hexdocs](https://hexdocs.pm/elixir/Task.Supervisor.html#start_link/1)" + ) + end + + test "Alias module function hover" do + text = """ + defmodule MyModule do + alias Task.Supervisor + + def hello() do + Supervisor.start_link() + end + end + """ + + {line, char} = {4, 15} + + assert {:ok, %{"contents" => %{kind: "markdown", value: v}}} = + Hover.hover(text, line, char, fake_dir()) + + assert String.starts_with?( + v, + "> Task.Supervisor.start_link(options \\\\\\\\ []) [view on hexdocs](https://hexdocs.pm/elixir/Task.Supervisor.html#start_link/1)" + ) + end + test "Erlang module hover is not support now" do text = """ defmodule MyModule do diff --git a/apps/language_server/test/providers/implementation_test.exs b/apps/language_server/test/providers/implementation_test.exs index 3f63e0d00..f95f0eca4 100644 --- a/apps/language_server/test/providers/implementation_test.exs +++ b/apps/language_server/test/providers/implementation_test.exs @@ -14,7 +14,7 @@ defmodule ElixirLS.LanguageServer.Providers.ImplementationTest do file_path = FixtureHelpers.get_path("example_behaviour.ex") text = File.read!(file_path) - uri = SourceFile.path_to_uri(file_path) + uri = SourceFile.Path.to_uri(file_path) {line, char} = {0, 43} diff --git a/apps/language_server/test/providers/references_test.exs b/apps/language_server/test/providers/references_test.exs index 334a9e55b..f26d87cc1 100644 --- a/apps/language_server/test/providers/references_test.exs +++ b/apps/language_server/test/providers/references_test.exs @@ -1,48 +1,84 @@ defmodule ElixirLS.LanguageServer.Providers.ReferencesTest do - use ExUnit.Case, async: true + use ExUnit.Case, async: false alias ElixirLS.LanguageServer.Providers.References alias ElixirLS.LanguageServer.SourceFile alias ElixirLS.LanguageServer.Test.FixtureHelpers + alias ElixirLS.LanguageServer.Tracer + alias ElixirLS.LanguageServer.Build require ElixirLS.Test.TextLoc - test "finds references to a function" do - file_path = FixtureHelpers.get_path("references_b.ex") + setup_all context do + File.rm_rf!(FixtureHelpers.get_path(".elixir_ls/calls.dets")) + {:ok, pid} = Tracer.start_link([]) + Tracer.set_project_dir(FixtureHelpers.get_path("")) + + compiler_options = Code.compiler_options() + Build.set_compiler_options(ignore_module_conflict: true) + + on_exit(fn -> + Code.compiler_options(compiler_options) + Process.monitor(pid) + Process.unlink(pid) + GenServer.stop(pid) + + receive do + {:DOWN, _, _, _, _} -> :ok + end + end) + + Code.compile_file(FixtureHelpers.get_path("references_referenced.ex")) + Code.compile_file(FixtureHelpers.get_path("references_imported.ex")) + Code.compile_file(FixtureHelpers.get_path("references_remote.ex")) + Code.compile_file(FixtureHelpers.get_path("uses_macro_a.ex")) + Code.compile_file(FixtureHelpers.get_path("macro_a.ex")) + {:ok, context} + end + + test "finds local, remote and imported references to a function" do + file_path = FixtureHelpers.get_path("references_referenced.ex") text = File.read!(file_path) - uri = SourceFile.path_to_uri(file_path) + uri = SourceFile.Path.to_uri(file_path) - {line, char} = {2, 8} + {line, char} = {1, 8} ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ - some_var = 42 + def referenced_fun do ^ """) - ElixirLS.Utils.TestUtils.assert_match_list( - References.references(text, uri, line, char, true), - [ - %{ - "range" => %{ - "start" => %{"line" => 2, "character" => 4}, - "end" => %{"line" => 2, "character" => 12} - }, - "uri" => uri - }, - %{ - "range" => %{ - "start" => %{"line" => 4, "character" => 12}, - "end" => %{"line" => 4, "character" => 20} - }, - "uri" => uri - } - ] - ) + list = References.references(text, uri, line, char, true) + + assert length(list) == 3 + assert Enum.any?(list, &(&1["uri"] |> String.ends_with?("references_remote.ex"))) + assert Enum.any?(list, &(&1["uri"] |> String.ends_with?("references_imported.ex"))) + assert Enum.any?(list, &(&1["uri"] |> String.ends_with?("references_referenced.ex"))) end - test "cannot find a references to a macro generated function call" do + test "finds local, remote and imported references to a macro" do + file_path = FixtureHelpers.get_path("references_referenced.ex") + text = File.read!(file_path) + uri = SourceFile.Path.to_uri(file_path) + + {line, char} = {8, 12} + + ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ + defmacro referenced_macro(clause, do: expression) do + ^ + """) + + list = References.references(text, uri, line, char, true) + + assert length(list) == 3 + assert Enum.any?(list, &(&1["uri"] |> String.ends_with?("references_remote.ex"))) + assert Enum.any?(list, &(&1["uri"] |> String.ends_with?("references_imported.ex"))) + assert Enum.any?(list, &(&1["uri"] |> String.ends_with?("references_referenced.ex"))) + end + + test "find a references to a macro generated function call" do file_path = FixtureHelpers.get_path("uses_macro_a.ex") text = File.read!(file_path) - uri = SourceFile.path_to_uri(file_path) + uri = SourceFile.Path.to_uri(file_path) {line, char} = {6, 13} ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ @@ -50,13 +86,21 @@ defmodule ElixirLS.LanguageServer.Providers.ReferencesTest do ^ """) - assert References.references(text, uri, line, char, true) == [] + assert References.references(text, uri, line, char, true) == [ + %{ + "range" => %{ + "end" => %{"character" => 16, "line" => 6}, + "start" => %{"character" => 4, "line" => 6} + }, + "uri" => uri + } + ] end test "finds a references to a macro imported function call" do file_path = FixtureHelpers.get_path("uses_macro_a.ex") text = File.read!(file_path) - uri = SourceFile.path_to_uri(file_path) + uri = SourceFile.Path.to_uri(file_path) {line, char} = {10, 4} ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ @@ -76,31 +120,60 @@ defmodule ElixirLS.LanguageServer.Providers.ReferencesTest do end test "finds references to a variable" do - file_path = FixtureHelpers.get_path("references_b.ex") + file_path = FixtureHelpers.get_path("references_referenced.ex") text = File.read!(file_path) - uri = SourceFile.path_to_uri(file_path) + uri = SourceFile.Path.to_uri(file_path) {line, char} = {4, 14} ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ - IO.puts(some_var + 1) + IO.puts(referenced_variable + 1) ^ """) assert References.references(text, uri, line, char, true) == [ %{ "range" => %{ - "end" => %{"character" => 12, "line" => 2}, + "end" => %{"character" => 23, "line" => 2}, "start" => %{"character" => 4, "line" => 2} }, "uri" => uri }, %{ "range" => %{ - "end" => %{"character" => 20, "line" => 4}, + "end" => %{"character" => 31, "line" => 4}, "start" => %{"character" => 12, "line" => 4} }, "uri" => uri } ] end + + test "finds references to an attribute" do + file_path = FixtureHelpers.get_path("references_referenced.ex") + text = File.read!(file_path) + uri = SourceFile.Path.to_uri(file_path) + {line, char} = {24, 5} + + ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ + @referenced_attribute \"123\" + ^ + """) + + assert References.references(text, uri, line, char, true) == [ + %{ + "range" => %{ + "end" => %{"character" => 23, "line" => 24}, + "start" => %{"character" => 2, "line" => 24} + }, + "uri" => uri + }, + %{ + "range" => %{ + "end" => %{"character" => 25, "line" => 27}, + "start" => %{"character" => 4, "line" => 27} + }, + "uri" => uri + } + ] + end end diff --git a/apps/language_server/test/providers/rename_test.exs b/apps/language_server/test/providers/rename_test.exs index 934ac38a3..62f97f235 100644 --- a/apps/language_server/test/providers/rename_test.exs +++ b/apps/language_server/test/providers/rename_test.exs @@ -1,13 +1,39 @@ defmodule ElixirLS.LanguageServer.Providers.RenameTest do use ExUnit.Case, async: true + alias ElixirLS.LanguageServer.Build alias ElixirLS.LanguageServer.Providers.Rename alias ElixirLS.LanguageServer.SourceFile alias ElixirLS.LanguageServer.Test.FixtureHelpers + alias ElixirLS.LanguageServer.Tracer # mix cmd --app language_server mix test test/providers/rename_test.exs @fake_uri "file:///World/Netherlands/Amsterdam/supercomputer/amazing.ex" + setup_all context do + File.rm_rf!(FixtureHelpers.get_path(".elixir_ls/calls.dets")) + {:ok, pid} = Tracer.start_link([]) + Tracer.set_project_dir(FixtureHelpers.get_path("")) + + compiler_options = Code.compiler_options() + Build.set_compiler_options(ignore_module_conflict: true) + + on_exit(fn -> + Process.monitor(pid) + Process.unlink(pid) + GenServer.stop(pid) + + receive do + {:DOWN, _, _, _, _} -> :ok + end + end) + + Code.compile_file(FixtureHelpers.get_path("rename_example.ex")) + Code.compile_file(FixtureHelpers.get_path("rename_example_b.ex")) + + {:ok, context} + end + test "rename blank space" do text = """ defmodule MyModule do @@ -36,14 +62,20 @@ defmodule ElixirLS.LanguageServer.Providers.RenameTest do # _a + b {line, char} = {3, 5} + source = %SourceFile{text: text, version: 0} + target_range = %{line: 2, start_char: 4, end_char: 5} + + Rename.prepare(source, @fake_uri, line, char) + |> assert_prepare_range_and_placeholder_is(target_range, "a") + edits = - Rename.rename(%SourceFile{text: text, version: 0}, @fake_uri, line, char, "test") - |> assert_return_structure_and_get_edits(@fake_uri, 1) + Rename.rename(source, @fake_uri, line, char, "test") + |> assert_return_structure_and_get_edits(@fake_uri, nil) expected_edits = [ %{line: 1, start_char: 10, end_char: 11}, - %{line: 2, start_char: 4, end_char: 5} + target_range ] |> get_expected_edits("test") @@ -62,50 +94,99 @@ defmodule ElixirLS.LanguageServer.Providers.RenameTest do # "Hello " <> ne_ma {line, char} = {3, 19} + source = %SourceFile{text: text, version: 0} + target_range = %{line: 2, start_char: 16, end_char: 20} + + Rename.prepare(source, @fake_uri, line, char) + |> assert_prepare_range_and_placeholder_is(target_range, "nema") + edits = Rename.rename( - %SourceFile{text: text, version: 0}, + source, @fake_uri, line, char, "name" ) - |> assert_return_structure_and_get_edits(@fake_uri, 1) + |> assert_return_structure_and_get_edits(@fake_uri, nil) expected_edits = [ %{line: 1, start_char: 12, end_char: 16}, - %{line: 2, start_char: 16, end_char: 20} + target_range ] |> get_expected_edits("name") assert sort_edit_by_start_line(edits) == expected_edits end + + test "renaming a variable definition works original -> new_original" do + text = """ + defmodule MyModule do + def hello do + original = "original" + new = original <> " new stuff" + end + end + """ + + # new = "#{original} + new stuff!" + {line, char} = {3, 6} + source = %SourceFile{text: text, version: 0} + target_range = %{line: 2, start_char: 4, end_char: 12} + + Rename.prepare(source, @fake_uri, line, char) + |> assert_prepare_range_and_placeholder_is(target_range, "original") + + edits = + Rename.rename( + source, + @fake_uri, + line, + char, + "new_original" + ) + |> assert_return_structure_and_get_edits(@fake_uri, nil) + + expected_edits = + [ + target_range, + %{line: 3, start_char: 10, end_char: 18} + ] + |> get_expected_edits("new_original") + + assert sort_edit_by_start_line(edits) == expected_edits + end end describe "renaming local function" do test "subtract -> new_subtract" do file_path = FixtureHelpers.get_path("rename_example.ex") text = File.read!(file_path) - uri = SourceFile.path_to_uri(file_path) + uri = SourceFile.Path.to_uri(file_path) # d = subtract(a, b) {line, char} = {6, 10} + source = %SourceFile{text: text, version: 0} + target_range = %{line: 5, start_char: 8, end_char: 16} + + Rename.prepare(source, @fake_uri, line, char) + |> assert_prepare_range_and_placeholder_is(target_range, "subtract") edits = Rename.rename( - %SourceFile{text: text, version: 0}, + source, uri, line, char, "new_subtract" ) - |> assert_return_structure_and_get_edits(uri, 1) + |> assert_return_structure_and_get_edits(uri, nil) expected_edits = [ - %{line: 5, start_char: 8, end_char: 16}, - %{line: 13, start_char: 7, end_char: 15} + target_range, + %{line: 15, start_char: 7, end_char: 15} ] |> get_expected_edits("new_subtract") @@ -115,24 +196,29 @@ defmodule ElixirLS.LanguageServer.Providers.RenameTest do test "rename function with multiple heads: add -> new_add" do file_path = FixtureHelpers.get_path("rename_example.ex") text = File.read!(file_path) - uri = SourceFile.path_to_uri(file_path) + uri = SourceFile.Path.to_uri(file_path) # c = add(a, b) {line, char} = {5, 9} + source = %SourceFile{text: text, version: 0} + target_range = %{line: 4, start_char: 8, end_char: 11} + + Rename.prepare(source, @fake_uri, line, char) + |> assert_prepare_range_and_placeholder_is(target_range, "add") edits = Rename.rename( - %SourceFile{text: text, version: 0}, + source, uri, line, char, "new_add" ) - |> assert_return_structure_and_get_edits(uri, 1) + |> assert_return_structure_and_get_edits(uri, nil) expected_edits = [ - %{line: 4, start_char: 8, end_char: 11}, + target_range, %{line: 6, start_char: 4, end_char: 7}, %{line: 9, start_char: 7, end_char: 10}, %{line: 10, start_char: 7, end_char: 10}, @@ -146,13 +232,17 @@ defmodule ElixirLS.LanguageServer.Providers.RenameTest do test "rename function defined in a different file ten -> new_ten" do file_path = FixtureHelpers.get_path("rename_example.ex") text = File.read!(file_path) - uri = SourceFile.path_to_uri(file_path) + uri = SourceFile.Path.to_uri(file_path) fn_definition_file_uri = - FixtureHelpers.get_path("rename_example_b.ex") |> SourceFile.path_to_uri() + FixtureHelpers.get_path("rename_example_b.ex") |> SourceFile.Path.to_uri() # b = ElixirLS.Test.RenameExampleB.ten() {line, char} = {4, 38} + source = %SourceFile{text: text, version: 0} + + Rename.prepare(source, uri, line, char) + |> assert_prepare_range_and_placeholder_is(%{line: 3, start_char: 8, end_char: 40}, "ten") assert {:ok, %{ @@ -160,21 +250,21 @@ defmodule ElixirLS.LanguageServer.Providers.RenameTest do %{ "textDocument" => %{ "uri" => ^uri, - "version" => 1 + "version" => nil }, "edits" => file_edits }, %{ "textDocument" => %{ "uri" => ^fn_definition_file_uri, - "version" => 1 + "version" => nil }, "edits" => fn_definition_file_edits } ] }} = Rename.rename( - %SourceFile{text: text, version: 0}, + source, uri, line, char, @@ -198,7 +288,7 @@ defmodule ElixirLS.LanguageServer.Providers.RenameTest do test "rename started with cursor at function definition" do file_path = FixtureHelpers.get_path("rename_example.ex") text = File.read!(file_path) - uri = SourceFile.path_to_uri(file_path) + uri = SourceFile.Path.to_uri(file_path) # defp _handle_error({:ok, message}) {line, char} = {4, 8} @@ -268,4 +358,19 @@ defmodule ElixirLS.LanguageServer.Providers.RenameTest do defp sort_edit_by_start_line(edits) do Enum.sort(edits, &(&1["range"].start.line < &2["range"].start.line)) end + + defp assert_prepare_range_and_placeholder_is( + prepare_result, + %{line: line, start_char: start_char, end_char: end_char} = _expected_range, + expected_placeholder + ) do + assert {:ok, + %{ + placeholder: expected_placeholder, + range: %{ + start: %{line: line, character: start_char}, + end: %{line: line, character: end_char} + } + }} == prepare_result + end end diff --git a/apps/language_server/test/providers/workspace_symbols_test.exs b/apps/language_server/test/providers/workspace_symbols_test.exs index dcf86a3af..f850ac77c 100644 --- a/apps/language_server/test/providers/workspace_symbols_test.exs +++ b/apps/language_server/test/providers/workspace_symbols_test.exs @@ -5,8 +5,7 @@ defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbolsTest do setup do alias ElixirLS.Utils.PacketCapture packet_capture = start_supervised!({PacketCapture, self()}) - - {:ok, pid} = WorkspaceSymbols.start_link(name: nil) + {:ok, pid} = start_supervised({WorkspaceSymbols, name: nil}) Process.group_leader(pid, packet_capture) state = :sys.get_state(pid) @@ -14,7 +13,7 @@ defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbolsTest do fixture_uri = ElixirLS.LanguageServer.Fixtures.WorkspaceSymbols.module_info(:compile)[:source] |> List.to_string() - |> ElixirLS.LanguageServer.SourceFile.path_to_uri() + |> ElixirLS.LanguageServer.SourceFile.Path.to_uri() :sys.replace_state(pid, fn _ -> %{ @@ -36,11 +35,6 @@ defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbolsTest do test "empty query", %{server: server} do assert {:ok, []} == WorkspaceSymbols.symbols("", server) - - assert_receive %{ - "method" => "window/logMessage", - "params" => %{"message" => "[ElixirLS WorkspaceSymbols] Updating index..."} - } end test "returns modules", %{server: server} do diff --git a/apps/language_server/test/server_test.exs b/apps/language_server/test/server_test.exs index c9faf3b0e..3b9ec9342 100644 --- a/apps/language_server/test/server_test.exs +++ b/apps/language_server/test/server_test.exs @@ -1,5 +1,5 @@ defmodule ElixirLS.LanguageServer.ServerTest do - alias ElixirLS.LanguageServer.{Server, Protocol, SourceFile} + alias ElixirLS.LanguageServer.{Server, Protocol, SourceFile, Tracer, Build} alias ElixirLS.Utils.PacketCapture alias ElixirLS.LanguageServer.Test.FixtureHelpers use ElixirLS.Utils.MixTest.Case, async: false @@ -7,14 +7,24 @@ defmodule ElixirLS.LanguageServer.ServerTest do doctest(Server) - defp initialize(server) do + setup_all do + on_exit(fn -> + Code.put_compiler_option(:tracers, []) + end) + end + + defp initialize(server, config \\ nil) do Server.receive_packet(server, initialize_req(1, root_uri(), %{})) Server.receive_packet(server, notification("initialized")) + config = (config || %{}) |> Map.merge(%{"dialyzerEnabled" => false}) + Server.receive_packet( server, - did_change_configuration(%{"elixirLS" => %{"dialyzerEnabled" => false}}) + did_change_configuration(%{"elixirLS" => config}) ) + + wait_until_compiled(server) end defp fake_initialize(server) do @@ -24,7 +34,7 @@ defmodule ElixirLS.LanguageServer.ServerTest do end defp root_uri do - SourceFile.path_to_uri(File.cwd!()) + SourceFile.Path.to_uri(File.cwd!()) end describe "initialize" do @@ -229,12 +239,13 @@ defmodule ElixirLS.LanguageServer.ServerTest do end setup context do - unless context[:skip_server] do + if context[:skip_server] do + :ok + else server = ElixirLS.LanguageServer.Test.ServerTestHelpers.start_server() + {:ok, tracer} = start_supervised(Tracer) - {:ok, %{server: server}} - else - :ok + {:ok, %{server: server, tracer: tracer}} end end @@ -531,7 +542,7 @@ defmodule ElixirLS.LanguageServer.ServerTest do ) in_fixture(__DIR__, "references", fn -> - uri = SourceFile.path_to_uri("lib/a.ex") + uri = SourceFile.Path.to_uri("lib/a.ex") fake_initialize(server) Server.receive_packet(server, did_open(uri, "elixir", 1, code)) Server.receive_packet(server, did_change(uri, 1, content_changes)) @@ -566,7 +577,7 @@ defmodule ElixirLS.LanguageServer.ServerTest do ) in_fixture(__DIR__, "references", fn -> - uri = SourceFile.path_to_uri("lib/a.ex") + uri = SourceFile.Path.to_uri("lib/a.ex") fake_initialize(server) Server.receive_packet(server, did_open(uri, "elixir", 1, code)) Server.receive_packet(server, did_change(uri, 1, content_changes)) @@ -719,7 +730,7 @@ defmodule ElixirLS.LanguageServer.ServerTest do Server.receive_packet(server, did_open(uri, "elixir", 1, code)) Server.receive_packet(server, completion_req(1, uri, 2, 25)) - resp = assert_receive(%{"id" => 1}, 1000) + resp = assert_receive(%{"id" => 1}, 5000) assert response(1, %{ "isIncomplete" => true, @@ -750,7 +761,7 @@ defmodule ElixirLS.LanguageServer.ServerTest do uri = ElixirLS.LanguageServer.Fixtures.ExampleBehaviour.module_info()[:compile][:source] |> to_string - |> SourceFile.path_to_uri() + |> SourceFile.Path.to_uri() assert_receive( response(1, %{ @@ -783,7 +794,7 @@ defmodule ElixirLS.LanguageServer.ServerTest do test "implementations found", %{server: server} do file_path = FixtureHelpers.get_path("example_behaviour.ex") text = File.read!(file_path) - uri = SourceFile.path_to_uri(file_path) + uri = SourceFile.Path.to_uri(file_path) fake_initialize(server) Server.receive_packet(server, did_open(uri, "elixir", 1, text)) @@ -918,7 +929,7 @@ defmodule ElixirLS.LanguageServer.ServerTest do uri = Path.join([root_uri(), "file.ex"]) uri - |> SourceFile.abs_path_from_uri() + |> SourceFile.Path.absolute_from_uri() |> File.write!("") code = """ @@ -961,8 +972,6 @@ defmodule ElixirLS.LanguageServer.ServerTest do # File is already formatted assert response(3, []) == resp - - wait_until_compiled(server) end) end @@ -1026,7 +1035,7 @@ defmodule ElixirLS.LanguageServer.ServerTest do @tag :fixture test "reports build diagnostics", %{server: server} do in_fixture(__DIR__, "build_errors", fn -> - error_file = SourceFile.path_to_uri("lib/has_error.ex") + error_file = SourceFile.Path.to_uri("lib/has_error.ex") initialize(server) @@ -1041,15 +1050,13 @@ defmodule ElixirLS.LanguageServer.ServerTest do ] }), 1000 - - wait_until_compiled(server) end) end @tag :fixture test "reports token missing error diagnostics", %{server: server} do in_fixture(__DIR__, "token_missing_error", fn -> - error_file = SourceFile.path_to_uri("lib/has_error.ex") + error_file = SourceFile.Path.to_uri("lib/has_error.ex") initialize(server) @@ -1064,15 +1071,13 @@ defmodule ElixirLS.LanguageServer.ServerTest do ] }), 1000 - - wait_until_compiled(server) end) end @tag :fixture test "reports build diagnostics on external resources", %{server: server} do in_fixture(__DIR__, "build_errors_on_external_resource", fn -> - error_file = SourceFile.path_to_uri("lib/template.eex") + error_file = SourceFile.Path.to_uri("lib/template.eex") initialize(server) @@ -1087,8 +1092,6 @@ defmodule ElixirLS.LanguageServer.ServerTest do ] }), 2000 - - wait_until_compiled(server) end) end @@ -1096,11 +1099,14 @@ defmodule ElixirLS.LanguageServer.ServerTest do test "finds references in non-umbrella project", %{server: server} do in_fixture(__DIR__, "references", fn -> file_path = "lib/b.ex" - file_uri = SourceFile.path_to_uri(file_path) + file_uri = SourceFile.Path.to_uri(file_path) text = File.read!(file_path) - reference_uri = SourceFile.path_to_uri("lib/a.ex") + reference_uri = SourceFile.Path.to_uri("lib/a.ex") + + Build.set_compiler_options() initialize(server) + Server.receive_packet(server, did_open(file_uri, "elixir", 1, text)) Server.receive_packet( @@ -1116,20 +1122,23 @@ defmodule ElixirLS.LanguageServer.ServerTest do "uri" => ^reference_uri } ]) = resp - - wait_until_compiled(server) end) + after + Code.put_compiler_option(:tracers, []) end @tag :fixture test "finds references in umbrella project", %{server: server} do in_fixture(__DIR__, "umbrella", fn -> file_path = "apps/app2/lib/app2.ex" - file_uri = SourceFile.path_to_uri(file_path) + file_uri = SourceFile.Path.to_uri(file_path) text = File.read!(file_path) - reference_uri = SourceFile.path_to_uri("apps/app1/lib/app1.ex") + reference_uri = SourceFile.Path.to_uri("apps/app1/lib/app1.ex") + + Build.set_compiler_options() initialize(server) + Server.receive_packet(server, did_open(file_uri, "elixir", 1, text)) Server.receive_packet( @@ -1145,9 +1154,9 @@ defmodule ElixirLS.LanguageServer.ServerTest do "uri" => ^reference_uri } ]) = resp - - wait_until_compiled(server) end) + after + Code.put_compiler_option(:tracers, []) end @tag fixture: true, skip_server: true @@ -1157,8 +1166,8 @@ defmodule ElixirLS.LanguageServer.ServerTest do # First to compile the applications and build the cache. # Second time to see if loads modules with_new_server(fn server -> + {:ok, _pid} = Tracer.start_link([]) initialize(server) - wait_until_compiled(server) end) # unload App2.Foo @@ -1167,10 +1176,9 @@ defmodule ElixirLS.LanguageServer.ServerTest do # re-visiting the same project with_new_server(fn server -> initialize(server) - wait_until_compiled(server) file_path = "apps/app1/lib/bar.ex" - uri = SourceFile.path_to_uri(file_path) + uri = SourceFile.Path.to_uri(file_path) code = """ defmodule Bar do @@ -1204,23 +1212,78 @@ defmodule ElixirLS.LanguageServer.ServerTest do test "returns code lenses for runnable tests", %{server: server} do in_fixture(__DIR__, "test_code_lens", fn -> file_path = "test/fixture_test.exs" - file_uri = SourceFile.path_to_uri(file_path) + file_uri = SourceFile.Path.to_uri(file_path) # this is not an abs path as returned by Path.absname # on Windows it's c:\asdf instead of c:/asdf - file_absolute_path = SourceFile.path_from_uri(file_uri) + file_absolute_path = SourceFile.Path.from_uri(file_uri) text = File.read!(file_path) - project_dir = - root_uri() - |> SourceFile.abs_path_from_uri() + project_dir = SourceFile.Path.absolute_from_uri(root_uri()) - initialize(server) + initialize(server, %{"enableTestLenses" => true}) + + Server.receive_packet(server, did_open(file_uri, "elixir", 1, text)) Server.receive_packet( server, - did_change_configuration(%{"elixirLS" => %{"enableTestLenses" => true}}) + code_lens_req(4, file_uri) ) + resp = assert_receive(%{"id" => 4}, 5000) + + assert response(4, [ + %{ + "command" => %{ + "arguments" => [ + %{ + "filePath" => ^file_absolute_path, + "testName" => "fixture test", + "projectDir" => ^project_dir + } + ], + "command" => "elixir.lens.test.run", + "title" => "Run test" + }, + "range" => %{ + "end" => %{"character" => 0, "line" => 3}, + "start" => %{"character" => 0, "line" => 3} + } + }, + %{ + "command" => %{ + "arguments" => [ + %{ + "filePath" => ^file_absolute_path, + "module" => "Elixir.TestCodeLensTest", + "projectDir" => ^project_dir + } + ], + "command" => "elixir.lens.test.run", + "title" => "Run tests in module" + }, + "range" => %{ + "end" => %{"character" => 0, "line" => 0}, + "start" => %{"character" => 0, "line" => 0} + } + } + ]) = resp + end) + end + + @tag :fixture + test "returns code lenses for runnable tests in umbrella apps", + %{ + server: server + } do + in_fixture(__DIR__, "umbrella_test_code_lens", fn -> + file_path = "apps/app1/test/fixture_custom_test.exs" + file_uri = SourceFile.Path.to_uri(file_path) + file_absolute_path = SourceFile.Path.from_uri(file_uri) + text = File.read!(file_path) + project_dir = SourceFile.Path.from_uri("#{root_uri()}/apps/app1") + + initialize(server, %{"enableTestLenses" => true}) + Server.receive_packet(server, did_open(file_uri, "elixir", 1, text)) Server.receive_packet( @@ -1253,7 +1316,7 @@ defmodule ElixirLS.LanguageServer.ServerTest do "arguments" => [ %{ "filePath" => ^file_absolute_path, - "module" => "Elixir.TestCodeLensTest", + "module" => "Elixir.App1.UmbrellaTestCodeLensTest", "projectDir" => ^project_dir } ], @@ -1266,8 +1329,6 @@ defmodule ElixirLS.LanguageServer.ServerTest do } } ]) = resp - - wait_until_compiled(server) end) end @@ -1277,7 +1338,7 @@ defmodule ElixirLS.LanguageServer.ServerTest do } do in_fixture(__DIR__, "test_code_lens", fn -> file_path = "test/fixture_test.exs" - file_uri = SourceFile.path_to_uri(file_path) + file_uri = SourceFile.Path.to_uri(file_path) text = File.read!(file_path) fake_initialize(server) @@ -1301,22 +1362,15 @@ defmodule ElixirLS.LanguageServer.ServerTest do } do in_fixture(__DIR__, "test_code_lens_custom_paths_and_pattern", fn -> file_path = "custom_path/fixture_custom_test.exs" - file_uri = SourceFile.path_to_uri(file_path) - file_absolute_path = SourceFile.path_from_uri(file_uri) + file_uri = SourceFile.Path.to_uri(file_path) + file_absolute_path = SourceFile.Path.from_uri(file_uri) text = File.read!(file_path) - project_dir = SourceFile.path_from_uri(root_uri()) + project_dir = SourceFile.Path.from_uri(root_uri()) - initialize(server) - - Server.receive_packet( - server, - did_change_configuration(%{"elixirLS" => %{"enableTestLenses" => true}}) - ) + initialize(server, %{"enableTestLenses" => true}) Server.receive_packet(server, did_open(file_uri, "elixir", 1, text)) - wait_until_compiled(server) - Server.receive_packet( server, code_lens_req(4, file_uri) @@ -1370,28 +1424,19 @@ defmodule ElixirLS.LanguageServer.ServerTest do } do in_fixture(__DIR__, "umbrella_test_code_lens_custom_path_and_pattern", fn -> file_path = "apps/app1/custom_path/fixture_custom_test.exs" - file_uri = SourceFile.path_to_uri(file_path) - file_absolute_path = SourceFile.path_from_uri(file_uri) + file_uri = SourceFile.Path.to_uri(file_path) + file_absolute_path = SourceFile.Path.from_uri(file_uri) text = File.read!(file_path) - project_dir = SourceFile.path_from_uri("#{root_uri()}/apps/app1") - - initialize(server) + project_dir = SourceFile.Path.from_uri("#{root_uri()}/apps/app1") - Server.receive_packet( - server, - did_change_configuration(%{ - "elixirLS" => %{ - "enableTestLenses" => true, - "testPaths" => %{"app1" => ["custom_path"]}, - "testPattern" => %{"app1" => "*_custom_test.exs"} - } - }) - ) + initialize(server, %{ + "enableTestLenses" => true, + "testPaths" => %{"app1" => ["custom_path"]}, + "testPattern" => %{"app1" => "*_custom_test.exs"} + }) Server.receive_packet(server, did_open(file_uri, "elixir", 1, text)) - wait_until_compiled(server) - Server.receive_packet( server, code_lens_req(4, file_uri) @@ -1456,8 +1501,6 @@ defmodule ElixirLS.LanguageServer.ServerTest do assert_receive notification("window/logMessage", %{ "message" => "Compile took" <> _ }) - - wait_until_compiled(server) end) end @@ -1482,6 +1525,70 @@ defmodule ElixirLS.LanguageServer.ServerTest do end end + describe "textDocument/codeAction" do + test "return code actions on unused variables", %{server: server} do + uri = "file:///file.ex" + fake_initialize(server) + + Server.receive_packet(server, did_open(uri, "elixir", 1, "")) + + Server.receive_packet( + server, + code_action_req(1, uri, [ + %{ + "message" => + "variable \"foo\" is unused (if the variable is not meant to be used, prefix it with an underscore)", + "range" => %{ + "end" => %{"character" => 13, "line" => 19}, + "start" => %{"character" => 4, "line" => 19} + }, + "severity" => 1, + "source" => "Elixir" + } + ]) + ) + + resp = assert_receive(%{"id" => 1}, 5000) + + assert response(1, [ + %{ + "edit" => %{ + "changes" => %{ + "file:///file.ex" => [ + %{ + "newText" => "_", + "range" => %{ + "end" => %{"character" => 4, "line" => 19}, + "start" => %{"character" => 4, "line" => 19} + } + } + ] + } + }, + "kind" => "quickfix", + "title" => "Add '_' to unused variable" + }, + %{ + "edit" => %{ + "changes" => %{ + "file:///file.ex" => [ + %{ + "newText" => "", + "range" => %{ + "end" => %{"character" => 13, "line" => 19}, + "start" => %{"character" => 4, "line" => 19} + } + } + ] + } + }, + "kind" => "quickfix", + "title" => "Remove unused variable" + } + ]) == resp + end + end + defp with_new_server(func) do server = start_supervised!({Server, nil}) packet_capture = start_supervised!({PacketCapture, self()}) diff --git a/apps/language_server/test/source_file/invalid_project_test.exs b/apps/language_server/test/source_file/invalid_project_test.exs new file mode 100644 index 000000000..7ae7e46d7 --- /dev/null +++ b/apps/language_server/test/source_file/invalid_project_test.exs @@ -0,0 +1,48 @@ +defmodule ElixirLS.LanguageServer.SourceFile.InvalidProjectTest do + use ExUnit.Case, async: false + + use Patch + alias ElixirLS.LanguageServer.SourceFile + import ExUnit.CaptureLog + + def appropriate_formatter_function_name(_) do + formatter_function = + if function_exported?(Mix.Tasks.Format, :formatter_for_file, 1) do + :formatter_for_file + else + :formatter_opts_for_file + end + + {:ok, formatter_name: formatter_function} + end + + describe "formatter_for " do + setup [:appropriate_formatter_function_name] + + test "should handle syntax errors", ctx do + patch(Mix.Tasks.Format, ctx.formatter_name, fn _ -> + raise %SyntaxError{file: ".formatter.exs", line: 1} + end) + + output = + capture_log(fn -> + assert :error = SourceFile.formatter_for("file:///root.ex") + end) + + assert String.contains?(output, "Unable to get formatter options") + end + + test "should handle compile errors", ctx do + patch(Mix.Tasks.Format, ctx.formatter_name, fn _ -> + raise %SyntaxError{file: ".formatter.exs", line: 1} + end) + + output = + capture_log(fn -> + assert :error = SourceFile.formatter_for("file:///root.ex") + end) + + assert String.contains?(output, "Unable to get formatter options") + end + end +end diff --git a/apps/language_server/test/source_file/path_test.exs b/apps/language_server/test/source_file/path_test.exs new file mode 100644 index 000000000..837013d42 --- /dev/null +++ b/apps/language_server/test/source_file/path_test.exs @@ -0,0 +1,191 @@ +defmodule ElixirLS.LanguageServer.SourceFile.PathTest do + use ExUnit.Case + use Patch + + import ElixirLS.LanguageServer.SourceFile.Path + import ElixirLS.LanguageServer.Test.PlatformTestHelpers + + defp patch_os(os_type, fun) do + test = self() + + spawn(fn -> + patch(ElixirLS.LanguageServer.SourceFile.Path, :os_type, os_type) + + try do + rv = fun.() + send(test, {:return, rv}) + rescue + e -> + send(test, {:raise, e, __STACKTRACE__}) + end + end) + + receive do + {:return, rv} -> + rv + + {:raise, %ExUnit.AssertionError{} = e, stack} -> + new_message = "In O/S #{inspect(os_type)} #{e.message}" + reraise(%ExUnit.AssertionError{e | message: new_message}, stack) + + {:raise, error, stack} -> + reraise(error, stack) + end + end + + def with_os(:windows, fun) do + patch_os({:win32, :whatever}, fun) + end + + def with_os(:linux, fun) do + patch_os({:unix, :linux}, fun) + end + + def with_os(:macos, fun) do + patch_os({:unix, :darwin}, fun) + end + + describe "from_uri/1" do + # tests based on cases from https://github.com/microsoft/vscode-uri/blob/master/src/test/uri.test.ts + + test "unix" do + with_os(:windows, fn -> + assert from_uri("file:///some/path") == "\\some\\path" + assert from_uri("file:///some/path/") == "\\some\\path\\" + assert from_uri("file:///nodes%2B%23.ex") == "\\nodes+#.ex" + end) + + with_os(:linux, fn -> + assert from_uri("file:///some/path") == "/some/path" + assert from_uri("file:///some/path/") == "/some/path/" + assert from_uri("file:///nodes%2B%23.ex") == "/nodes+#.ex" + end) + end + + test "UNC" do + with_os(:windows, fn -> + assert from_uri("file://shares/files/c%23/p.cs") == "\\\\shares\\files\\c#\\p.cs" + + assert from_uri("file://monacotools1/certificates/SSL/") == + "\\\\monacotools1\\certificates\\SSL\\" + + assert from_uri("file://monacotools1/") == "\\\\monacotools1\\" + end) + + with_os(:linux, fn -> + assert from_uri("file://shares/files/c%23/p.cs") == "//shares/files/c#/p.cs" + + assert from_uri("file://monacotools1/certificates/SSL/") == + "//monacotools1/certificates/SSL/" + + assert from_uri("file://monacotools1/") == "//monacotools1/" + end) + end + + test "no `path` in URI" do + with_os(:windows, fn -> + assert from_uri("file://%2Fhome%2Fticino%2Fdesktop%2Fcpluscplus%2Ftest.cpp") == "\\" + end) + + with_os(:linux, fn -> + assert from_uri("file://%2Fhome%2Fticino%2Fdesktop%2Fcpluscplus%2Ftest.cpp") == "/" + end) + end + + test "windows drive letter" do + with_os(:windows, fn -> + assert from_uri("file:///c:/test/me") == "c:\\test\\me" + assert from_uri("file:///c%3A/test/me") == "c:\\test\\me" + assert from_uri("file:///C:/test/me/") == "c:\\test\\me\\" + assert from_uri("file:///_:/path") == "\\_:\\path" + + assert from_uri( + "file:///c:/Source/Z%C3%BCrich%20or%20Zurich%20(%CB%88zj%CA%8A%C9%99r%C9%AAk,/Code/resources/app/plugins" + ) == "c:\\Source\\Zürich or Zurich (ˈzjʊərɪk,\\Code\\resources\\app\\plugins" + end) + + with_os(:linux, fn -> + assert from_uri("file:///c:/test/me") == "/c:/test/me" + assert from_uri("file:///c%3A/test/me") == "/c:/test/me" + assert from_uri("file:///C:/test/me/") == "/C:/test/me/" + assert from_uri("file:///_:/path") == "/_:/path" + + assert from_uri( + "file:///c:/Source/Z%C3%BCrich%20or%20Zurich%20(%CB%88zj%CA%8A%C9%99r%C9%AAk,/Code/resources/app/plugins" + ) == "/c:/Source/Zürich or Zurich (ˈzjʊərɪk,/Code/resources/app/plugins" + end) + end + + test "wrong schema" do + assert_raise ArgumentError, fn -> + from_uri("untitled:Untitled-1") + end + + assert_raise ArgumentError, fn -> + from_uri("unsaved://343C3EE7-D575-486D-9D33-93AFFAF773BD") + end + end + end + + describe "to_uri/1" do + # tests based on cases from https://github.com/microsoft/vscode-uri/blob/master/src/test/uri.test.ts + test "unix path" do + with_os(:linux, fn -> + assert "file:///nodes%2B%23.ex" == to_uri("/nodes+#.ex") + assert "file:///coding/c%23/project1" == to_uri("/coding/c#/project1") + + assert "file:///Users/jrieken/Code/_samples/18500/M%C3%B6del%20%2B%20Other%20Th%C3%AEng%C3%9F/model.js" == + to_uri("/Users/jrieken/Code/_samples/18500/Mödel + Other Thîngß/model.js") + + assert "file:///foo/%25A0.txt" == to_uri("/foo/%A0.txt") + assert "file:///foo/%252e.txt" == to_uri("/foo/%2e.txt") + end) + end + + test "windows path" do + if is_windows() do + assert "file:///c%3A/win/path" == to_uri("c:/win/path") + assert "file:///c%3A/win/path" == to_uri("C:/win/path") + assert "file:///c%3A/win/path" == to_uri("c:/win/path/") + assert "file:///c%3A/win/path" == to_uri("/c:/win/path") + + assert "file:///c%3A/win/path" == to_uri("c:\\win\\path") + assert "file:///c%3A/win/path" == to_uri("c:\\win/path") + + assert "file:///c%3A/test%20with%20%25/path" == + to_uri("c:\\test with %\\path") + + assert "file:///c%3A/test%20with%20%2525/c%23code" == + to_uri("c:\\test with %25\\c#code") + end + end + + test "relative path" do + cwd = File.cwd!() + + uri = to_uri("a.file") + + assert from_uri(uri) == + cwd + |> Path.join("a.file") + |> maybe_convert_path_separators() + + uri = to_uri("./foo/bar") + + assert from_uri(uri) == + cwd + |> Path.join("foo/bar") + |> maybe_convert_path_separators + end + + test "UNC path" do + if is_windows() do + assert "file://sh%C3%A4res/path/c%23/plugin.json" == + to_uri("\\\\shäres\\path\\c#\\plugin.json") + + assert "file://localhost/c%24/GitDevelopment/express" == + to_uri("\\\\localhost\\c$\\GitDevelopment\\express") + end + end + end +end diff --git a/apps/language_server/test/source_file_test.exs b/apps/language_server/test/source_file_test.exs index f5b79a56a..54c922934 100644 --- a/apps/language_server/test/source_file_test.exs +++ b/apps/language_server/test/source_file_test.exs @@ -1,7 +1,6 @@ defmodule ElixirLS.LanguageServer.SourceFileTest do use ExUnit.Case, async: true use ExUnitProperties - import ElixirLS.LanguageServer.Test.PlatformTestHelpers alias ElixirLS.LanguageServer.SourceFile @@ -643,182 +642,77 @@ defmodule ElixirLS.LanguageServer.SourceFileTest do end end - # tests basing on cases from https://github.com/microsoft/vscode-uri/blob/master/src/test/uri.test.ts - describe "path_from_uri" do - test "unix" do - path = SourceFile.path_from_uri("file:///some/path") - - if is_windows() do - assert path == "\\some\\path" - else - assert path == "/some/path" - end - - path = SourceFile.path_from_uri("file:///some/path/") - - if is_windows() do - assert path == "\\some\\path\\" - else - assert path == "/some/path/" - end - - path = SourceFile.path_from_uri("file:///nodes%2B%23.ex") - - if is_windows() do - assert path == "\\nodes+#.ex" - else - assert path == "/nodes+#.ex" - end + describe "positions" do + test "lsp_position_to_elixir empty" do + assert {1, 1} == SourceFile.lsp_position_to_elixir("", {0, 0}) end - test "UNC" do - path = SourceFile.path_from_uri("file://shares/files/c%23/p.cs") - - if is_windows() do - assert path == "\\\\shares\\files\\c#\\p.cs" - else - assert path == "//shares/files/c#/p.cs" - end - - path = SourceFile.path_from_uri("file://monacotools1/certificates/SSL/") - - if is_windows() do - assert path == "\\\\monacotools1\\certificates\\SSL\\" - else - assert path == "//monacotools1/certificates/SSL/" - end - - path = SourceFile.path_from_uri("file://monacotools1/") - - if is_windows() do - assert path == "\\\\monacotools1\\" - else - assert path == "//monacotools1/" - end + test "lsp_position_to_elixir single first char" do + assert {1, 1} == SourceFile.lsp_position_to_elixir("abcde", {0, 0}) end - test "no `path` in URI" do - path = SourceFile.path_from_uri("file://%2Fhome%2Fticino%2Fdesktop%2Fcpluscplus%2Ftest.cpp") - - if is_windows() do - assert path == "\\" - else - assert path == "/" - end + test "lsp_position_to_elixir single line" do + assert {1, 2} == SourceFile.lsp_position_to_elixir("abcde", {0, 1}) end - test "windows drive letter" do - path = SourceFile.path_from_uri("file:///c:/test/me") - - if is_windows() do - assert path == "c:\\test\\me" - else - assert path == "/c:/test/me" - end - - path = SourceFile.path_from_uri("file:///c%3A/test/me") - - if is_windows() do - assert path == "c:\\test\\me" - else - assert path == "/c:/test/me" - end - - path = SourceFile.path_from_uri("file:///C:/test/me/") - - if is_windows() do - assert path == "c:\\test\\me\\" - else - assert path == "/C:/test/me/" - end - - path = SourceFile.path_from_uri("file:///_:/path") - - if is_windows() do - assert path == "\\_:\\path" - else - assert path == "/_:/path" - end - - path = - SourceFile.path_from_uri( - "file:///c:/Source/Z%C3%BCrich%20or%20Zurich%20(%CB%88zj%CA%8A%C9%99r%C9%AAk,/Code/resources/app/plugins" - ) - - if is_windows() do - assert path == "c:\\Source\\Zürich or Zurich (ˈzjʊərɪk,\\Code\\resources\\app\\plugins" - else - assert path == "/c:/Source/Zürich or Zurich (ˈzjʊərɪk,/Code/resources/app/plugins" - end + # This is not specified in LSP but some clients fail to synchronize text properly + test "lsp_position_to_elixir single line before line start" do + assert {1, 1} == SourceFile.lsp_position_to_elixir("abcde", {0, -1}) end - test "wrong schema" do - assert_raise ArgumentError, fn -> - SourceFile.path_from_uri("untitled:Untitled-1") - end - - assert_raise ArgumentError, fn -> - SourceFile.path_from_uri("unsaved://343C3EE7-D575-486D-9D33-93AFFAF773BD") - end + # LSP spec 3.17 https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position + # position character If the character value is greater than the line length it defaults back to the line length + test "lsp_position_to_elixir single line after line end" do + assert {1, 6} == SourceFile.lsp_position_to_elixir("abcde", {0, 15}) + assert {1, 1} == SourceFile.lsp_position_to_elixir("", {0, 15}) end - end - - # tests basing on cases from https://github.com/microsoft/vscode-uri/blob/master/src/test/uri.test.ts - describe "path_to_uri" do - test "unix path" do - unless is_windows() do - assert "file:///nodes%2B%23.ex" == SourceFile.path_to_uri("/nodes+#.ex") - assert "file:///coding/c%23/project1" == SourceFile.path_to_uri("/coding/c#/project1") - assert "file:///Users/jrieken/Code/_samples/18500/M%C3%B6del%20%2B%20Other%20Th%C3%AEng%C3%9F/model.js" == - SourceFile.path_to_uri( - "/Users/jrieken/Code/_samples/18500/Mödel + Other Thîngß/model.js" - ) - - assert "file:///foo/%25A0.txt" == SourceFile.path_to_uri("/foo/%A0.txt") - assert "file:///foo/%252e.txt" == SourceFile.path_to_uri("/foo/%2e.txt") - end + test "lsp_position_to_elixir single line utf8" do + assert {1, 2} == SourceFile.lsp_position_to_elixir("🏳️‍🌈abcde", {0, 6}) end - test "windows path" do - if is_windows() do - assert "file:///c%3A/win/path" == SourceFile.path_to_uri("c:/win/path") - assert "file:///c%3A/win/path" == SourceFile.path_to_uri("C:/win/path") - assert "file:///c%3A/win/path" == SourceFile.path_to_uri("c:/win/path/") - assert "file:///c%3A/win/path" == SourceFile.path_to_uri("/c:/win/path") - - assert "file:///c%3A/win/path" == SourceFile.path_to_uri("c:\\win\\path") - assert "file:///c%3A/win/path" == SourceFile.path_to_uri("c:\\win/path") + test "lsp_position_to_elixir multi line" do + assert {2, 2} == SourceFile.lsp_position_to_elixir("abcde\n1234", {1, 1}) + end - assert "file:///c%3A/test%20with%20%25/path" == - SourceFile.path_to_uri("c:\\test with %\\path") + # This is not specified in LSP but some clients fail to synchronize text properly + test "lsp_position_to_elixir multi line before first line" do + assert {1, 1} == SourceFile.lsp_position_to_elixir("abcde\n1234", {-1, 2}) + end - assert "file:///c%3A/test%20with%20%2525/c%23code" == - SourceFile.path_to_uri("c:\\test with %25\\c#code") - end + # This is not specified in LSP but some clients fail to synchronize text properly + test "lsp_position_to_elixir multi line after last line" do + assert {2, 5} == SourceFile.lsp_position_to_elixir("abcde\n1234", {8, 2}) end - test "relative path" do - cwd = File.cwd!() + test "elixir_position_to_lsp empty" do + assert {0, 0} == SourceFile.elixir_position_to_lsp("", {1, 1}) + end - uri = SourceFile.path_to_uri("a.file") + test "elixir_position_to_lsp single line first char" do + assert {0, 0} == SourceFile.elixir_position_to_lsp("abcde", {1, 1}) + end - assert SourceFile.path_from_uri(uri) == - maybe_convert_path_separators(Path.join(cwd, "a.file")) + test "elixir_position_to_lsp single line" do + assert {0, 1} == SourceFile.elixir_position_to_lsp("abcde", {1, 2}) + end - uri = SourceFile.path_to_uri("./foo/bar") + test "elixir_position_to_lsp single line utf8" do + assert {0, 6} == SourceFile.elixir_position_to_lsp("🏳️‍🌈abcde", {1, 2}) + end - assert SourceFile.path_from_uri(uri) == - maybe_convert_path_separators(Path.join(cwd, "foo/bar")) + test "elixir_position_to_lsp multi line" do + assert {1, 1} == SourceFile.elixir_position_to_lsp("abcde\n1234", {2, 2}) end - test "UNC path" do - if is_windows() do - assert "file://sh%C3%A4res/path/c%23/plugin.json" == - SourceFile.path_to_uri("\\\\shäres\\path\\c#\\plugin.json") + test "sanity check" do + text = "aąłsd🏳️‍🌈abcde" + + for i <- 0..String.length(text) do + elixir_pos = {1, i + 1} + lsp_pos = SourceFile.elixir_position_to_lsp(text, elixir_pos) - assert "file://localhost/c%24/GitDevelopment/express" == - SourceFile.path_to_uri("\\\\localhost\\c$\\GitDevelopment\\express") + assert elixir_pos == SourceFile.lsp_position_to_elixir(text, lsp_pos) end end end diff --git a/apps/language_server/test/support/fixtures/.elixir_ls/.gitkeep b/apps/language_server/test/support/fixtures/.elixir_ls/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/language_server/test/support/fixtures/example_docs.ex b/apps/language_server/test/support/fixtures/example_docs.ex index b551b42b9..1c0a74bec 100644 --- a/apps/language_server/test/support/fixtures/example_docs.ex +++ b/apps/language_server/test/support/fixtures/example_docs.ex @@ -2,7 +2,7 @@ defmodule ElixirLS.LanguageServer.Fixtures.ExampleDocs do @doc """ The summary - Ths rest + This rest """ @spec add(a_big_name :: integer, b_big_name :: integer) :: integer def add(a, b) do diff --git a/apps/language_server/test/support/fixtures/references_a.ex b/apps/language_server/test/support/fixtures/references_a.ex deleted file mode 100644 index c9087d4d6..000000000 --- a/apps/language_server/test/support/fixtures/references_a.ex +++ /dev/null @@ -1,5 +0,0 @@ -defmodule ElixirLS.Test.ReferencesA do - def a_fun do - ElixirLS.Test.ReferencesB.b_fun() - end -end diff --git a/apps/language_server/test/support/fixtures/references_b.ex b/apps/language_server/test/support/fixtures/references_b.ex deleted file mode 100644 index 0bbb1f3c6..000000000 --- a/apps/language_server/test/support/fixtures/references_b.ex +++ /dev/null @@ -1,8 +0,0 @@ -defmodule ElixirLS.Test.ReferencesB do - def b_fun do - some_var = 42 - - IO.puts(some_var + 1) - :ok - end -end diff --git a/apps/language_server/test/support/fixtures/references_imported.ex b/apps/language_server/test/support/fixtures/references_imported.ex new file mode 100644 index 000000000..ae20fd4c8 --- /dev/null +++ b/apps/language_server/test/support/fixtures/references_imported.ex @@ -0,0 +1,13 @@ +defmodule ElixirLS.Test.ReferencesImported do + import ElixirLS.Test.ReferencesReferenced + + def uses_fun do + referenced_fun() + end + + def uses_macro(a) do + referenced_macro a do + :ok + end + end +end diff --git a/apps/language_server/test/support/fixtures/references_referenced.ex b/apps/language_server/test/support/fixtures/references_referenced.ex new file mode 100644 index 000000000..7f422cdc0 --- /dev/null +++ b/apps/language_server/test/support/fixtures/references_referenced.ex @@ -0,0 +1,30 @@ +defmodule ElixirLS.Test.ReferencesReferenced do + def referenced_fun do + referenced_variable = 42 + + IO.puts(referenced_variable + 1) + :ok + end + + defmacro referenced_macro(clause, do: expression) do + quote do + if(!unquote(clause), do: unquote(expression)) + end + end + + def uses_fun(_a) do + referenced_fun() + end + + def uses_macro(a) do + referenced_macro a do + :ok + end + end + + @referenced_attribute "123" + + def uses_attribute do + @referenced_attribute + end +end diff --git a/apps/language_server/test/support/fixtures/references_remote.ex b/apps/language_server/test/support/fixtures/references_remote.ex new file mode 100644 index 000000000..409d21201 --- /dev/null +++ b/apps/language_server/test/support/fixtures/references_remote.ex @@ -0,0 +1,13 @@ +defmodule ElixirLS.Test.ReferencesRemote do + require ElixirLS.Test.ReferencesReferenced, as: ReferencesReferenced + + def uses_fun do + ReferencesReferenced.referenced_fun() + end + + def uses_macro(a) do + ReferencesReferenced.referenced_macro a do + :ok + end + end +end diff --git a/apps/language_server/test/support/fixtures/rename_example.ex b/apps/language_server/test/support/fixtures/rename_example.ex index 2110e22a3..2ee17656f 100644 --- a/apps/language_server/test/support/fixtures/rename_example.ex +++ b/apps/language_server/test/support/fixtures/rename_example.ex @@ -11,5 +11,7 @@ defmodule ElixirLS.Test.RenameExample do defp add(a, b) when is_integer(a) and is_integer(b), do: a + b defp add(a, b) when is_binary(a) and is_binary(b), do: a <> b + def add(a, b, c), do: a + b + c + defp subtract(a, b), do: a - b end diff --git a/apps/language_server/test/support/server_test_helpers.ex b/apps/language_server/test/support/server_test_helpers.ex index 04a3a4f31..e36613055 100644 --- a/apps/language_server/test/support/server_test_helpers.ex +++ b/apps/language_server/test/support/server_test_helpers.ex @@ -9,6 +9,31 @@ defmodule ElixirLS.LanguageServer.Test.ServerTestHelpers do def start_server do packet_capture = start_supervised!({PacketCapture, self()}) + # :logger application is already started + # replace console logger with LSP + Application.put_env(:logger, :backends, [Logger.Backends.JsonRpc]) + + Application.put_env(:logger, Logger.Backends.JsonRpc, + level: :debug, + format: "$message", + metadata: [] + ) + + {:ok, _logger_backend} = Logger.add_backend(Logger.Backends.JsonRpc) + :ok = Logger.remove_backend(:console, flush: true) + + # Logger.add_backend returns Logger.Watcher pid + # the handler is supervised by :gen_event and the pid cannot be received via public api + # instead we call it to set group leader in the callback + :gen_event.call(Logger, Logger.Backends.JsonRpc, {:set_group_leader, packet_capture}) + + ExUnit.Callbacks.on_exit(fn -> + Application.put_env(:logger, :backends, [:console]) + + {:ok, _} = Logger.add_backend(:console) + :ok = Logger.remove_backend(Logger.Backends.JsonRpc, flush: false) + end) + server = start_supervised!({Server, nil}) Process.group_leader(server, packet_capture) diff --git a/apps/language_server/test/test_helper.exs b/apps/language_server/test/test_helper.exs index 5f20b536b..dd9854a0a 100644 --- a/apps/language_server/test/test_helper.exs +++ b/apps/language_server/test/test_helper.exs @@ -1,7 +1,3 @@ -if Version.match?(System.version(), ">= 1.11.0") do - Code.put_compiler_option(:warnings_as_errors, true) -end - Application.put_env(:language_server, :test_mode, true) Application.ensure_started(:stream_data) ExUnit.start(exclude: [pending: true]) diff --git a/apps/language_server/test/tracer_test.exs b/apps/language_server/test/tracer_test.exs new file mode 100644 index 000000000..67a7e46a2 --- /dev/null +++ b/apps/language_server/test/tracer_test.exs @@ -0,0 +1,224 @@ +defmodule ElixirLS.LanguageServer.TracerTest do + use ExUnit.Case, async: false + alias ElixirLS.LanguageServer.Tracer + alias ElixirLS.LanguageServer.Test.FixtureHelpers + + setup context do + File.rm_rf!(FixtureHelpers.get_path(".elixir_ls/tracer_db.manifest")) + File.rm_rf!(FixtureHelpers.get_path(".elixir_ls/calls.dets")) + File.rm_rf!(FixtureHelpers.get_path(".elixir_ls/modules.dets")) + {:ok, _pid} = start_supervised(Tracer) + + {:ok, context} + end + + test "project dir is nil" do + assert GenServer.call(Tracer, :get_project_dir) == nil + end + + test "set project dir" do + project_path = FixtureHelpers.get_path("") + + Tracer.set_project_dir(project_path) + + assert GenServer.call(Tracer, :get_project_dir) == project_path + end + + test "saves DETS" do + Tracer.set_project_dir(FixtureHelpers.get_path("")) + + Tracer.save() + + assert File.exists?(FixtureHelpers.get_path(".elixir_ls/calls.dets")) + assert File.exists?(FixtureHelpers.get_path(".elixir_ls/modules.dets")) + end + + describe "call trace" do + setup context do + Tracer.set_project_dir(FixtureHelpers.get_path("")) + + {:ok, context} + end + + defp sorted_calls() do + :ets.tab2list(:"#{Tracer}:calls") |> Enum.sort() + end + + test "trace is empty" do + assert sorted_calls() == [] + end + + test "registers calls same function different files" do + Tracer.trace( + {:remote_function, [line: 12, column: 2], CalledModule, :called, 1}, + %Macro.Env{ + module: CallingModule, + file: "calling_module.ex" + } + ) + + Tracer.trace( + {:remote_function, [line: 13, column: 3], CalledModule, :called, 1}, + %Macro.Env{ + module: OtherCallingModule, + file: "other_calling_module.ex" + } + ) + + assert [ + {{CalledModule, :called, 1}, + %{ + "calling_module.ex" => [ + %{ + callee: {CalledModule, :called, 1}, + column: 2, + file: "calling_module.ex", + line: 12 + } + ], + "other_calling_module.ex" => [ + %{ + callee: {CalledModule, :called, 1}, + column: 3, + file: "other_calling_module.ex", + line: 13 + } + ] + }} + ] == sorted_calls() + end + + test "registers calls same function in one file" do + Tracer.trace( + {:remote_function, [line: 12, column: 2], CalledModule, :called, 1}, + %Macro.Env{ + module: CallingModule, + file: "calling_module.ex" + } + ) + + Tracer.trace( + {:remote_function, [line: 13, column: 3], CalledModule, :called, 1}, + %Macro.Env{ + module: CallingModule, + file: "calling_module.ex" + } + ) + + assert [ + {{CalledModule, :called, 1}, + %{ + "calling_module.ex" => [ + %{ + callee: {CalledModule, :called, 1}, + column: 3, + file: "calling_module.ex", + line: 13 + }, + %{ + callee: {CalledModule, :called, 1}, + column: 2, + file: "calling_module.ex", + line: 12 + } + ] + }} + ] == sorted_calls() + end + + test "registers calls different functions" do + Tracer.trace( + {:remote_function, [line: 12, column: 2], CalledModule, :called, 1}, + %Macro.Env{ + module: CallingModule, + file: "calling_module.ex" + } + ) + + Tracer.trace( + {:remote_function, [line: 13, column: 3], CalledModule, :other_called, 1}, + %Macro.Env{ + module: OtherCallingModule, + file: "other_calling_module.ex" + } + ) + + assert [ + {{CalledModule, :called, 1}, + %{ + "calling_module.ex" => [ + %{ + callee: {CalledModule, :called, 1}, + column: 2, + file: "calling_module.ex", + line: 12 + } + ] + }}, + {{CalledModule, :other_called, 1}, + %{ + "other_calling_module.ex" => [ + %{ + callee: {CalledModule, :other_called, 1}, + column: 3, + file: "other_calling_module.ex", + line: 13 + } + ] + }} + ] == sorted_calls() + end + + test "deletes calls by file" do + Tracer.trace( + {:remote_function, [line: 12, column: 2], CalledModule, :called, 1}, + %Macro.Env{ + module: CallingModule, + file: "calling_module.ex" + } + ) + + Tracer.trace( + {:remote_function, [line: 13, column: 3], CalledModule, :called, 1}, + %Macro.Env{ + module: OtherCallingModule, + file: "other_calling_module.ex" + } + ) + + Tracer.delete_calls_by_file("other_calling_module.ex") + + assert [ + {{CalledModule, :called, 1}, + %{ + "calling_module.ex" => [ + %{ + callee: {CalledModule, :called, 1}, + column: 2, + file: "calling_module.ex", + line: 12 + } + ] + }} + ] == sorted_calls() + + Tracer.delete_calls_by_file("calling_module.ex") + + assert [] == sorted_calls() + end + end + + describe "manifest" do + test "return nil when not found" do + project_path = FixtureHelpers.get_path("") + assert nil == Tracer.read_manifest(project_path) + end + + test "reads manifest" do + project_path = FixtureHelpers.get_path("") + Tracer.write_manifest(project_path) + + assert 1 == Tracer.read_manifest(project_path) + end + end +end diff --git a/apps/language_server/test_fixtures/fixtures/project_with_tests/mix.exs b/apps/language_server/test_fixtures/fixtures/project_with_tests/mix.exs new file mode 100644 index 000000000..71a23daa8 --- /dev/null +++ b/apps/language_server/test_fixtures/fixtures/project_with_tests/mix.exs @@ -0,0 +1,9 @@ +defmodule ProjectWithTests.MixProject do + use Mix.Project + + def project do + [app: :project_with_tests, version: "0.1.0"] + end + + def application, do: [] +end diff --git a/apps/language_server/test_fixtures/fixtures/project_with_tests/test/error_test.exs b/apps/language_server/test_fixtures/fixtures/project_with_tests/test/error_test.exs new file mode 100644 index 000000000..2ef75b8c6 --- /dev/null +++ b/apps/language_server/test_fixtures/fixtures/project_with_tests/test/error_test.exs @@ -0,0 +1,21 @@ +defmodule ErrorTest do + use ExUnit.Case + + defmodule ModuleWithoutTests do + end + + test "fixture test" do + assert true + end + + describe "describe with test" do + test "fixture test" do + assert true + end + end + + describe "describe without test" do + end + + test "this will be a test in future" do +end diff --git a/apps/language_server/test_fixtures/fixtures/project_with_tests/test/fixture_test.exs b/apps/language_server/test_fixtures/fixtures/project_with_tests/test/fixture_test.exs new file mode 100644 index 000000000..dd215cb29 --- /dev/null +++ b/apps/language_server/test_fixtures/fixtures/project_with_tests/test/fixture_test.exs @@ -0,0 +1,21 @@ +defmodule FixtureTest do + use ExUnit.Case + + defmodule ModuleWithoutTests do + end + + test "fixture test" do + assert true + end + + describe "describe with test" do + test "fixture test" do + assert true + end + end + + describe "describe without test" do + end + + test "this will be a test in future" +end diff --git a/docs/getting-started/neovim.md b/docs/getting-started/neovim.md index df6583400..959ecec93 100644 --- a/docs/getting-started/neovim.md +++ b/docs/getting-started/neovim.md @@ -1,4 +1,6 @@ -# Setup +# NeoVim + +## Setup There are several plugins available for NeoVim: diff --git a/docs/getting-started/vscode.md b/docs/getting-started/vscode.md index b062513f3..7c110530f 100644 --- a/docs/getting-started/vscode.md +++ b/docs/getting-started/vscode.md @@ -1,4 +1,4 @@ -# Emacs +# VSCode ## Setup diff --git a/guides/initialization.md b/guides/initialization.md index 6b66f5774..0705f7659 100644 --- a/guides/initialization.md +++ b/guides/initialization.md @@ -4,10 +4,10 @@ When launching the elixir_ls server using the scripts, the initialization steps 1. Replace default IO with Json RPC notifications 2. Starts Mix -3. Starts the :language_server application +3. Starts the :language_server application 4. Overrides default Mix.Shell 5. Ensure the Hex version is accepted -6. Start receiving requet/responses +6. Start receiving requests/responses 7. Gets the "initialize" request 8. Starts building/analyzing the project @@ -23,9 +23,9 @@ The servers delegate the callbacks to the module `ElixirLS.LanguageServer.JsonRp ElixirLS uses standard Mix tooling. It will need this to retrieve project configuration, dependencies and so on. It heavily uses private Mix APIs here to start and retrieve paths for dependencies, archives and so on. -## Starts the :language_server application +## Starts the :language_server application -The main entry point for the language server is its application module `ElixirLS.LanguageServer`. It starts a supervisor with a few children. +The main entry point for the language server is its application module `ElixirLS.LanguageServer`. It starts a supervisor with a few children. The first one is the server itself `ElixirLS.LanguageServer.Server`. This is a named `GenServer` that holds the state for the project. That state has things like: @@ -47,7 +47,7 @@ Mix might have some "yes or no" questions that would not be possible to reply in ## Ensure the Hex version is accepted -Before building the project and start answering requests from the client, it must ensure that the Hex version is correct. If it is not, it might have trouble building things. +Before building the project and start answering requests from the client, it must ensure that the Hex version is correct. If it is not, it might have trouble building things. This is also a private Mix API. @@ -112,11 +112,11 @@ When the language server needs to "reload" the project it must first: - compile only the project's `mix.exs` file. This is to ensure it is properly sourced and that it can use it for reading project metadata; - ensure that it does not override the server logger config; -This process is handled on the private function `reload_project/0`. One interesting trick it uses is that it sets a different build path using `Mix.ProjectStack.post_config/1` (which is a private API). This is the point where it will create a `.elixir_ls` directory on your project. +This process is handled on the private function `reload_project/0`. One interesting trick it uses is that it sets a different build path using `Mix.ProjectStack.post_config/1` (which is a private API). This is the point where it will create a `.elixir_ls` directory on your project. After reloading, the BEAM instance is free of old code and is ready to fetch/compile/analyze the project. -### Analyzing the project +### Analyzing the project Even after build is finished, things are not yet done. Both ElixirLS and ElixirSense need dialyzer information for some introspection. So, after build is done it sends a message that is handled at `ElixirLS.LanguageServer.Server.handle_build_result/3`. @@ -126,6 +126,6 @@ Here is the point in time where it will build the [PLT (a Persistent Lookup Tabl There are many things done in this module. It tries to be smart about analyzing only the modules that have changed. It does that by first checking the integrity of the PLT and then loading all modules from the PLT using `:dialyzer_plt.all_modules/1`. -If it finds that there is a difference, than it calculates this difference (using `MapSet`) to separate stale modules from non-stale. Then it delegates do the `ElixirLS.LanguageServer.Dialyzer.Analyzer` module for the proper analysis run. +If it finds that there is a difference, than it calculates this difference (using `MapSet`) to separate stale modules from non-stale. Then it delegates do the `ElixirLS.LanguageServer.Dialyzer.Analyzer` module for the proper analysis run. In the end it will publish a message that the analysis has finished, which will be delegated all the way back to the server module. There it handles the results accordingly and finally it will be ready to introspect code. diff --git a/mix.exs b/mix.exs index 72cdbe02e..ec5cb2450 100644 --- a/mix.exs +++ b/mix.exs @@ -9,16 +9,17 @@ defmodule ElixirLS.Mixfile do start_permanent: Mix.env() == :prod, build_per_environment: false, deps: deps(), - elixir: ">= 1.10.0", + elixir: ">= 1.12.3", dialyzer: [ - plt_add_apps: [:dialyxir, :debugger, :dialyzer, :hipe], + plt_add_apps: [:dialyxir_vendored, :debugger, :dialyzer, :ex_unit], flags: [ # enable only to verify error handling # :unmatched_returns, :error_handling, - :race_conditions, :unknown, - :underspecs + :underspecs, + :extra_return, + :missing_return ] ] ] diff --git a/mix.lock b/mix.lock index 70ecd4a42..01824dd8a 100644 --- a/mix.lock +++ b/mix.lock @@ -1,13 +1,15 @@ %{ "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, + "dialyxir_vendored": {:git, "https://github.com/elixir-lsp/dialyxir.git", "b610d1a87885576e0e2aa4cfb5e176072bcd9457", [branch: "vendored"]}, "docsh": {:hex, :docsh, "0.7.2", "f893d5317a0e14269dd7fe79cf95fb6b9ba23513da0480ec6e77c73221cae4f2", [:rebar3], [{:providers, "1.8.1", [hex: :providers, repo: "hexpm", optional: false]}], "hexpm", "4e7db461bb07540d2bc3d366b8513f0197712d0495bb85744f367d3815076134"}, - "elixir_sense": {:git, "https://github.com/elixir-lsp/elixir_sense.git", "33df514a1254455f54cb069999454c7e8586eb2d", []}, + "elixir_sense": {:git, "https://github.com/elixir-lsp/elixir_sense.git", "ea9eadbec00edef3b98f5389c509ddb81ffcacd0", []}, "erl2ex": {:git, "https://github.com/dazuma/erl2ex.git", "244c2d9ed5805ef4855a491d8616b8842fef7ca4", []}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "getopt": {:hex, :getopt, "1.0.1", "c73a9fa687b217f2ff79f68a3b637711bb1936e712b521d8ce466b29cbf7808a", [:rebar3], [], "hexpm", "53e1ab83b9ceb65c9672d3e7a35b8092e9bdc9b3ee80721471a161c10c59959c"}, - "jason_vendored": {:git, "https://github.com/elixir-lsp/jason.git", "ee95ca80cd67b3a499a14f469536140935eb4483", [branch: "vendored"]}, + "jason_vendored": {:git, "https://github.com/elixir-lsp/jason.git", "e23c65b98411a3066ca73534b4aed1d23bcf0356", [branch: "vendored"]}, "mix_task_archive_deps": {:git, "https://github.com/elixir-lsp/mix_task_archive_deps.git", "30fa76221def649286835685fec5d151be83c354", []}, "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, + "patch": {:hex, :patch, "0.12.0", "2da8967d382bade20344a3e89d618bfba563b12d4ac93955468e830777f816b0", [:mix], [], "hexpm", "ffd0e9a7f2ad5054f37af84067ee88b1ad337308a1cb227e181e3967127b0235"}, "path_glob_vendored": {:git, "https://github.com/elixir-lsp/path_glob.git", "965350dc41def7be4a70a23904195c733a2ecc84", [branch: "vendored"]}, "providers": {:hex, :providers, "1.8.1", "70b4197869514344a8a60e2b2a4ef41ca03def43cfb1712ecf076a0f3c62f083", [:rebar3], [{:getopt, "1.0.1", [hex: :getopt, repo: "hexpm", optional: false]}], "hexpm", "e45745ade9c476a9a469ea0840e418ab19360dc44f01a233304e118a44486ba0"}, "stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"},