├── .credo.exs ├── .dialyzer_ignore.exs ├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ ├── ci.yaml │ ├── lint-commit.yaml │ ├── release.yaml │ └── sponsors.yaml ├── .gitignore ├── .mise.toml ├── .release-please-manifest.json ├── .tool-versions ├── ARCHITECTURE.md ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bin ├── 7z └── start ├── commitlint.config.js ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── runtime.exs └── test.exs ├── docker-compose.yml ├── flake.lock ├── flake.nix ├── grafana-datasources.yaml ├── justfile ├── lib ├── next_ls.ex └── next_ls │ ├── aliases.ex │ ├── application.ex │ ├── autocomplete.ex │ ├── code_actionable.ex │ ├── commands.ex │ ├── commands │ ├── alias.ex │ └── pipe.ex │ ├── db.ex │ ├── db │ ├── activity.ex │ ├── format.ex │ ├── query.ex │ └── schema.ex │ ├── definition.ex │ ├── diagnostic_cache.ex │ ├── docs.ex │ ├── document_symbol.ex │ ├── extensions │ ├── credo_extension.ex │ ├── credo_extension │ │ ├── code_action.ex │ │ └── code_action │ │ │ └── remove_debugger.ex │ ├── elixir_extension.ex │ └── elixir_extension │ │ ├── code_action.ex │ │ └── code_action │ │ ├── require.ex │ │ ├── undefined_function.ex │ │ └── unused_variable.ex │ ├── helpers │ ├── ast_helpers.ex │ ├── ast_helpers │ │ └── variables.ex │ └── edit_helpers.ex │ ├── logger.ex │ ├── lsp_supervisor.ex │ ├── opentelemetry │ ├── gen_lsp.ex │ └── schematic.ex │ ├── parser.ex │ ├── progress.ex │ ├── registry.ex │ ├── runtime.ex │ ├── runtime │ ├── bundled_elixir.ex │ ├── sidecar.ex │ └── supervisor.ex │ ├── snippet.ex │ └── updater.ex ├── mix.exs ├── mix.lock ├── otel-collector.yaml ├── package.json ├── package.nix ├── priv ├── .keep ├── cmd ├── monkey │ ├── _next_ls_private_compiler.ex │ └── _next_ls_private_credo.ex ├── plts │ └── .keep └── precompiled-1-17-1.zip ├── prometheus.yaml ├── release-please-config.json ├── tempo.yaml ├── test ├── next_ls │ ├── alias_test.exs │ ├── autocomplete_test.exs │ ├── commands │ │ ├── alias_test.exs │ │ └── pipe_test.exs │ ├── completions_test.exs │ ├── definition_test.exs │ ├── dependency_test.exs │ ├── diagnostics_test.exs │ ├── docs_test.exs │ ├── document_symbol_test.exs │ ├── extensions │ │ ├── credo_extension │ │ │ └── remove_debugger_test.exs │ │ ├── credo_extension_test.exs │ │ ├── elixir_extension │ │ │ ├── code_action │ │ │ │ ├── require_test.exs │ │ │ │ ├── undefined_function_test.exs │ │ │ │ └── unused_variable_test.exs │ │ │ └── code_action_test.exs │ │ └── elixir_extension_test.exs │ ├── helpers │ │ ├── ast_helpers │ │ │ └── variables_test.exs │ │ └── ast_helpers_test.exs │ ├── hover_test.exs │ ├── pipe_test.exs │ ├── references_test.exs │ ├── runtime_test.exs │ ├── snippet_test.exs │ ├── updater_test.exs │ └── workspaces_test.exs ├── next_ls_test.exs ├── support │ └── utils.ex └── test_helper.exs └── yarn.lock /.dialyzer_ignore.exs: -------------------------------------------------------------------------------- 1 | [ 2 | {"lib/next_ls/lsp_supervisor.ex", :exact_eq}, 3 | {"lib/next_ls/runtime.ex", :exact_eq}, 4 | {"lib/next_ls/progress.ex", :pattern_match} 5 | ] 6 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | locals_without_parens: [ 3 | assert_result: 2, 4 | assert_notification: 2, 5 | assert_result: 3, 6 | assert_notification: 3, 7 | notify: 2, 8 | request: 2, 9 | assert_match: 1 10 | ], 11 | line_length: 120, 12 | import_deps: [:gen_lsp], 13 | plugins: [Styler], 14 | inputs: [ 15 | ".formatter.exs", 16 | "{config,lib,test}/**/*.{ex,exs}", 17 | "priv/**/*.ex" 18 | ] 19 | ] 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | timezone: UTC 7 | interval: weekly 8 | time: "11:00" 9 | open-pull-requests-limit: 5 10 | commit-message: 11 | prefix: "chore" 12 | include: "scope" 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | push: 5 | branches: main 6 | 7 | concurrency: 8 | group: ${{ github.ref }} 9 | cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} 10 | 11 | jobs: 12 | tests: 13 | runs-on: ${{matrix.os}} 14 | name: Test (${{matrix.os}}) - spitfire=${{matrix.spitfire}} 15 | 16 | strategy: 17 | matrix: 18 | spitfire: [0, 1] 19 | os: 20 | - ubuntu-latest 21 | - macos-14 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: DeterminateSystems/nix-installer-action@main 26 | - uses: cachix/cachix-action@v15 27 | with: 28 | name: elixir-tools 29 | authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' 30 | skipPush: true 31 | 32 | - uses: actions/cache@v4 33 | with: 34 | path: | 35 | deps 36 | _build 37 | key: ${{ matrix.os }}-mix-${{ hashFiles('**/flake.nix') }}-${{ hashFiles('**/mix.lock') }} 38 | restore-keys: | 39 | ${{ matrix.os }}-mix-${{ hashFiles('**/flake.nix') }}- 40 | 41 | - name: Install Dependencies 42 | run: nix develop --command bash -c 'mix deps.get' 43 | 44 | - name: Start EPMD 45 | run: nix develop --command bash -c 'epmd -daemon' 46 | 47 | - name: Compile 48 | env: 49 | MIX_ENV: test 50 | run: nix develop --command bash -c 'mix compile' 51 | 52 | - name: remove tmp dir 53 | run: rm -rf tmp 54 | 55 | - name: Run Tests 56 | env: 57 | NEXTLS_SPITFIRE_ENABLED: ${{ matrix.spitfire }} 58 | run: nix develop --command bash -c "elixir --erl '-kernel prevent_overlapping_partitions false' -S mix test --max-cases 1" 59 | 60 | formatter: 61 | runs-on: ubuntu-latest 62 | name: Formatter 63 | 64 | steps: 65 | - uses: actions/checkout@v4 66 | - uses: DeterminateSystems/nix-installer-action@main 67 | - uses: cachix/cachix-action@v15 68 | with: 69 | name: elixir-tools 70 | authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' 71 | skipPush: true 72 | - uses: actions/cache@v4 73 | with: 74 | path: | 75 | deps 76 | _build 77 | key: ${{ runner.os }}-mix-${{ hashFiles('**/flake.nix') }}-${{ hashFiles('**/mix.lock') }} 78 | restore-keys: | 79 | ${{ runner.os }}-mix-${{ hashFiles('**/flake.nix') }}- 80 | 81 | - name: Install Dependencies 82 | run: nix develop --command bash -c 'mix deps.get' 83 | 84 | - name: Run Formatter 85 | run: nix develop --command bash -c 'mix format --check-formatted' 86 | 87 | credo: 88 | runs-on: ubuntu-latest 89 | name: Credo 90 | 91 | steps: 92 | - uses: actions/checkout@v4 93 | - uses: DeterminateSystems/nix-installer-action@main 94 | - uses: cachix/cachix-action@v15 95 | with: 96 | name: elixir-tools 97 | authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' 98 | skipPush: true 99 | - uses: actions/cache@v4 100 | with: 101 | path: | 102 | deps 103 | _build 104 | key: ${{ runner.os }}-mix-${{ hashFiles('**/flake.nix') }}-${{ hashFiles('**/mix.lock') }} 105 | restore-keys: | 106 | ${{ runner.os }}-mix-${{ hashFiles('**/flake.nix') }}- 107 | 108 | - name: Install Dependencies 109 | run: nix develop --command bash -c 'mix deps.get' 110 | 111 | - name: Run Formatter 112 | run: nix develop --command bash -c 'mix credo' 113 | 114 | dialyzer: 115 | runs-on: ubuntu-latest 116 | steps: 117 | - uses: actions/checkout@v4 118 | - uses: DeterminateSystems/nix-installer-action@main 119 | - uses: cachix/cachix-action@v15 120 | with: 121 | name: elixir-tools 122 | authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' 123 | skipPush: true 124 | 125 | # Don't cache PLTs based on mix.lock hash, as Dialyzer can incrementally update even old ones 126 | # Cache key based on Elixir & Erlang version (also useful when running in matrix) 127 | - name: Restore PLT cache 128 | uses: actions/cache/restore@v4 129 | id: plt_cache 130 | with: 131 | key: ${{ runner.os }}-mix-${{ hashFiles('**/flake.nix') }}-${{ hashFiles('**/mix.lock') }} 132 | restore-keys: | 133 | ${{ runner.os }}-mix-${{ hashFiles('**/flake.nix') }}- 134 | path: | 135 | priv/plts 136 | 137 | - name: Install Dependencies 138 | run: nix develop --command bash -c 'mix deps.get' 139 | 140 | # Create PLTs if no cache was found 141 | - name: Create PLTs 142 | if: steps.plt_cache.outputs.cache-hit != 'true' 143 | run: nix develop --command bash -c 'mix dialyzer --plt' 144 | 145 | # By default, the GitHub Cache action will only save the cache if all steps in the job succeed, 146 | # so we separate the cache restore and save steps in case running dialyzer fails. 147 | - name: Save PLT cache 148 | uses: actions/cache/save@v4 149 | if: steps.plt_cache.outputs.cache-hit != 'true' 150 | id: plt_cache_save 151 | with: 152 | key: ${{ runner.os }}-mix-${{ hashFiles('**/flake.nix') }}-${{ hashFiles('**/mix.lock') }} 153 | path: | 154 | priv/plts 155 | 156 | - name: Run dialyzer 157 | run: nix develop --command bash -c 'mix dialyzer --format github' 158 | 159 | release-test: 160 | runs-on: ${{matrix.os.name}} 161 | name: Release Test (${{matrix.os.name}}) 162 | 163 | strategy: 164 | matrix: 165 | os: 166 | - name: ubuntu-latest 167 | target: linux_amd64 168 | - name: macos-14 169 | target: darwin_arm64 170 | - name: macos-13 171 | target: darwin_amd64 172 | 173 | steps: 174 | - uses: actions/checkout@v4 175 | - uses: DeterminateSystems/nix-installer-action@main 176 | - uses: cachix/cachix-action@v15 177 | with: 178 | name: elixir-tools 179 | authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' 180 | skipPush: true 181 | - uses: actions/cache@v4 182 | with: 183 | path: | 184 | deps 185 | key: ${{ matrix.os.name }}-mix-prod-${{ hashFiles('**/flake.nix') }}-${{ hashFiles('**/mix.lock') }} 186 | restore-keys: | 187 | ${{ matrix.os.name }}-mix-prod-${{ hashFiles('**/flake.nix') }}- 188 | 189 | - name: Install Dependencies 190 | run: nix develop --command bash -c 'mix deps.get --only prod' 191 | 192 | - name: Release 193 | env: 194 | MIX_ENV: prod 195 | BURRITO_TARGET: ${{ matrix.os.target }} 196 | run: nix develop --command bash -c 'mix release' 197 | 198 | nix-build: 199 | strategy: 200 | matrix: 201 | os: [ubuntu-latest, macos-13, macos-14] 202 | runs-on: ${{matrix.os}} 203 | 204 | steps: 205 | - uses: actions/checkout@v4 206 | - uses: cachix/install-nix-action@v30 207 | with: 208 | github_access_token: ${{ secrets.GITHUB_TOKEN }} 209 | - uses: cachix/cachix-action@v15 210 | with: 211 | name: elixir-tools 212 | authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' 213 | skipPush: true 214 | - run: nix build --accept-flake-config 215 | - run: nix flake check --accept-flake-config 216 | -------------------------------------------------------------------------------- /.github/workflows/lint-commit.yaml: -------------------------------------------------------------------------------- 1 | name: Lint Commit 2 | on: 3 | pull_request: 4 | types: 5 | - opened 6 | - reopened 7 | - synchronize 8 | - edited 9 | 10 | jobs: 11 | commitlint: 12 | runs-on: ubuntu-latest 13 | name: commitlint 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Install Deps 18 | run: yarn install 19 | - name: Lint PR Title 20 | run: echo "${{ github.event.pull_request.title }}" | yarn commitlint 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | commitlint: 13 | runs-on: ubuntu-latest 14 | name: commitlint 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - name: Install Deps 21 | run: yarn install 22 | - name: Lint PR Title 23 | run: echo "nice" # yarn commitlint --from "${{ github.event.before }}" 24 | 25 | release: 26 | name: release 27 | needs: commitlint 28 | runs-on: ubuntu-latest 29 | outputs: 30 | release_created: ${{ steps.release.outputs.release_created }} 31 | tag_name: ${{ steps.release.outputs.tag_name }} 32 | steps: 33 | - uses: googleapis/release-please-action@v4 34 | id: release 35 | 36 | draft: 37 | name: draft 38 | needs: release 39 | env: 40 | GH_TOKEN: ${{ github.token }} 41 | runs-on: ubuntu-latest 42 | if: ${{ needs.release.outputs.release_created }} 43 | steps: 44 | - run: gh release edit ${{ needs.release.outputs.tag_name }} --draft=true --repo='elixir-tools/next-ls' 45 | 46 | build: 47 | needs: [release, draft] 48 | runs-on: macos-14 49 | if: ${{ needs.release.outputs.release_created }} 50 | env: 51 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | NEXTLS_RELEASE_MODE: "burrito" 53 | steps: 54 | - uses: actions/checkout@v4 55 | - uses: DeterminateSystems/nix-installer-action@main 56 | - uses: cachix/cachix-action@v15 57 | with: 58 | name: elixir-tools 59 | authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' 60 | - run: nix develop --command bash -c 'mix local.hex --force' 61 | - run: nix develop --command bash -c 'mix local.rebar --force' 62 | - run: nix develop --command bash -c 'mix deps.get --only prod' 63 | - run: chmod +x priv/cmd 64 | - run: nix develop --command bash -c 'mix release' 65 | env: 66 | MIX_ENV: prod 67 | - name: Create Checksum 68 | run: | 69 | cd ./burrito_out 70 | chmod +x ./* 71 | shasum -a 256 ./* > next_ls_checksums.txt 72 | cd .. 73 | - name: Upload to release 74 | env: 75 | GITHUB_TOKEN: ${{ secrets.token }} 76 | run: gh release upload ${{ needs.release.outputs.tag_name }} ./burrito_out/* 77 | 78 | nix-build: 79 | name: build nix flakes and push to cachix 80 | needs: [release, draft] 81 | if: ${{ needs.release.outputs.release_created }} 82 | strategy: 83 | matrix: 84 | os: [ubuntu-latest, macos-13, macos-14] 85 | runs-on: ${{matrix.os}} 86 | steps: 87 | - uses: actions/checkout@v4 88 | - uses: cachix/install-nix-action@v30 89 | with: 90 | github_access_token: ${{ secrets.GITHUB_TOKEN }} 91 | - uses: cachix/cachix-action@v15 92 | with: 93 | name: elixir-tools 94 | authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' 95 | - run: nix build --accept-flake-config 96 | - run: nix flake check --accept-flake-config 97 | 98 | publish: 99 | name: publish 100 | needs: [release, draft, build] 101 | runs-on: ubuntu-latest 102 | env: 103 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 104 | steps: 105 | - run: gh release edit ${{ needs.release.outputs.tag_name }} --draft=false --repo='elixir-tools/next-ls' 106 | 107 | homebrew: 108 | needs: [release, draft, publish] 109 | runs-on: ubuntu-latest 110 | steps: 111 | - name: Bump Homebrew formula 112 | uses: dawidd6/action-homebrew-bump-formula@v4 113 | with: 114 | token: ${{secrets.GH_API_KEY}} 115 | no_fork: true 116 | tap: elixir-tools/tap 117 | formula: next-ls 118 | tag: ${{ needs.release.outputs.tag_name }} 119 | -------------------------------------------------------------------------------- /.github/workflows/sponsors.yaml: -------------------------------------------------------------------------------- 1 | name: Generate Sponsors README 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: 0 0 * * 0 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 🛎️ 16 | uses: actions/checkout@v4 17 | 18 | - name: Generate Sponsors 💖 19 | uses: JamesIves/github-sponsors-readme-action@v1 20 | with: 21 | token: ${{ secrets.SPONSORS_TOKEN }} 22 | marker: rest 23 | maximum: 24999 24 | file: 'README.md' 25 | 26 | - name: Generate Sponsors 💖 27 | uses: JamesIves/github-sponsors-readme-action@v1 28 | with: 29 | token: ${{ secrets.SPONSORS_TOKEN }} 30 | marker: gold 31 | minimum: 25000 32 | file: 'README.md' 33 | 34 | - name: Create Pull Request 35 | uses: peter-evans/create-pull-request@v7 36 | with: 37 | commit-message: "docs: update sponsors" 38 | title: "docs: update sponsors" 39 | branch: create-pull-request/update-sponsors 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | next_ls-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | burrito_out 28 | result 29 | 30 | # Ignore dialyzer plt files 31 | /priv/plts/*.plt 32 | /priv/plts/*.plt.hash 33 | node_modules 34 | 35 | tempo-data 36 | -------------------------------------------------------------------------------- /.mise.toml: -------------------------------------------------------------------------------- 1 | [env] 2 | KERL_BUILD_DOCS = "yes" 3 | 4 | [tools] 5 | erlang = "27.0" 6 | elixir = "1.17.0" 7 | zig = "0.11.0" 8 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "0.23.3" 3 | } 4 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 27.0 2 | elixir 1.17.0 3 | zig 0.11.0 4 | -------------------------------------------------------------------------------- /ARCHITECTURE.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | ## Supervision Tree 4 | 5 | [![](https://mermaid.ink/img/pako:eNp9U8tymzAU_RVGq2SGEED4AYvOxHactnFcT8iqkIUKF1tjkKgQqV2P_73iYVLog5XmPO49nJFOKOIxIA8lKf8R7YiQ2ssiZJr67oI1HOTKN-7yPKURkZSzV-3m5oM2C25bauVv_DIH8UYLLm5fa2PjntXKRad8IcX-D2mruu9UzyWTNIP_iZfBAzC115iVSQKixz1cIi8o2TJeSBrNSbSDnuhjcHXZBltaSHG87vHzLs3iyEhGo38l-dRuU2ADF-W3rSD5Trs_SGCF6qtoiM_BkvMObIcAi5vDXE3TEi5ARX23Ns0MBg32tHW15OOwxt-D1-NWXT-zNsRjjT8FfaPh0xgiInqi9UDUkusq_Yari1OJvgSQ0gO9OFcVmVK2r8lNcFV8T6kEfD3o4C9FLHkag6h9_d9EOspAZITG6tqeKixEcgcZhMhTx5iIfYhCdlY6UkruH1mEPClK0FGZx0RCdTkEyZCXkLRQKMRUcvHUvIP6OegoJwx5J3RAnmUaIzwZW87UtvF0MnZHOjoib2QatjPGjouxM7HG07OOfnKuhlqGaeKRO3Wx6VrYnjh2Pe1rTVYxzr8AUbMQ1w?type=png)](https://mermaid.live/edit#pako:eNp9U8tymzAU_RVGq2SGEED4AYvOxHactnFcT8iqkIUKF1tjkKgQqV2P_73iYVLog5XmPO49nJFOKOIxIA8lKf8R7YiQ2ssiZJr67oI1HOTKN-7yPKURkZSzV-3m5oM2C25bauVv_DIH8UYLLm5fa2PjntXKRad8IcX-D2mruu9UzyWTNIP_iZfBAzC115iVSQKixz1cIi8o2TJeSBrNSbSDnuhjcHXZBltaSHG87vHzLs3iyEhGo38l-dRuU2ADF-W3rSD5Trs_SGCF6qtoiM_BkvMObIcAi5vDXE3TEi5ARX23Ns0MBg32tHW15OOwxt-D1-NWXT-zNsRjjT8FfaPh0xgiInqi9UDUkusq_Yari1OJvgSQ0gO9OFcVmVK2r8lNcFV8T6kEfD3o4C9FLHkag6h9_d9EOspAZITG6tqeKixEcgcZhMhTx5iIfYhCdlY6UkruH1mEPClK0FGZx0RCdTkEyZCXkLRQKMRUcvHUvIP6OegoJwx5J3RAnmUaIzwZW87UtvF0MnZHOjoib2QatjPGjouxM7HG07OOfnKuhlqGaeKRO3Wx6VrYnjh2Pe1rTVYxzr8AUbMQ1w) 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Mitchell Hanberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /bin/7z: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 7zz "$@" 4 | -------------------------------------------------------------------------------- /bin/start: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | cd "$(dirname "$0")"/.. || exit 1 4 | 5 | mix run --no-halt -e "Application.ensure_all_started(:next_ls)" -- "$@" 6 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'] 3 | } 4 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :next_ls, :indexing_timeout, 100 4 | 5 | case System.get_env("NEXTLS_RELEASE_MODE", "plain") do 6 | "burrito" -> 7 | config :next_ls, arg_parser: {Burrito.Util.Args, :get_arguments, []} 8 | 9 | "plain" -> 10 | config :next_ls, arg_parser: {System, :argv, []} 11 | end 12 | 13 | import_config "#{config_env()}.exs" 14 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :logger, :default_formatter, format: "\n$time $metadata[$level] $message\n", metadata: [:id] 4 | 5 | config :logger, :default_handler, 6 | config: [ 7 | file: ~c".elixir-tools/next-ls.log", 8 | filesync_repeat_interval: 5000, 9 | file_check: 5000, 10 | max_no_bytes: 10_000_000, 11 | max_no_files: 5, 12 | compress_on_rotate: true 13 | ] 14 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :logger, :default_formatter, format: "\n$time $metadata[$level] $message\n", metadata: [:id] 4 | 5 | config :logger, :default_handler, 6 | config: [ 7 | file: ~c".elixir-tools/next-ls.log", 8 | filesync_repeat_interval: 5000, 9 | file_check: 5000, 10 | max_no_bytes: 10_000_000, 11 | max_no_files: 5, 12 | compress_on_rotate: true 13 | ] 14 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | if System.get_env("NEXTLS_OTEL") == "1" do 4 | config :next_ls, 5 | otel: true 6 | 7 | config :opentelemetry_exporter, 8 | otlp_protocol: :grpc, 9 | otlp_endpoint: "http://localhost:4317" 10 | else 11 | config :opentelemetry, traces_exporter: :none 12 | end 13 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :gen_lsp, :exit_on_end, false 4 | 5 | config :logger, :default_handler, config: [type: :standard_error] 6 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | tempo: 4 | image: grafana/tempo:latest 5 | command: [ "-config.file=/etc/tempo.yaml" ] 6 | volumes: 7 | - ./tempo.yaml:/etc/tempo.yaml 8 | - ./tempo-data:/tmp/tempo 9 | ports: 10 | - "14268:14268" # jaeger ingest 11 | - "3200:3200" # tempo 12 | # - "4317:4317" # otlp grpc 13 | # - "4318:4318" # otlp http 14 | - "9411:9411" # zipkin 15 | 16 | otel-collector: 17 | image: otel/opentelemetry-collector:0.61.0 18 | command: [ "--config=/etc/otel-collector.yaml" ] 19 | volumes: 20 | - ./otel-collector.yaml:/etc/otel-collector.yaml 21 | ports: 22 | - "4317:4317" # otlp grpc 23 | - "4318:4318" # otlp http 24 | 25 | prometheus: 26 | image: prom/prometheus:latest 27 | command: 28 | - --config.file=/etc/prometheus.yaml 29 | - --web.enable-remote-write-receiver 30 | - --enable-feature=exemplar-storage 31 | volumes: 32 | - ./prometheus.yaml:/etc/prometheus.yaml 33 | ports: 34 | - "9090:9090" 35 | 36 | grafana: 37 | image: grafana/grafana:9.4.3 38 | volumes: 39 | - ./grafana-datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml 40 | environment: 41 | - GF_AUTH_ANONYMOUS_ENABLED=true 42 | - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin 43 | - GF_AUTH_DISABLE_LOGIN_FORM=true 44 | - GF_FEATURE_TOGGLES_ENABLE=traceqlEditor 45 | ports: 46 | - "3000:3000" 47 | 48 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1735471104, 6 | "narHash": "sha256-0q9NGQySwDQc7RhAV2ukfnu7Gxa5/ybJ2ANT8DQrQrs=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "88195a94f390381c6afcdaa933c2f6ff93959cb4", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "NixOS", 14 | "ref": "nixos-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs", 22 | "zigpkgs": "zigpkgs" 23 | } 24 | }, 25 | "zigpkgs": { 26 | "locked": { 27 | "lastModified": 1704057613, 28 | "narHash": "sha256-5tPUpZlCpgqDQVUDlmhDhKn1h0A68jba8/DYie+yNC4=", 29 | "owner": "NixOS", 30 | "repo": "nixpkgs", 31 | "rev": "592a779f3c5e7bce1a02027abe11b7996816223f", 32 | "type": "github" 33 | }, 34 | "original": { 35 | "owner": "NixOS", 36 | "repo": "nixpkgs", 37 | "rev": "592a779f3c5e7bce1a02027abe11b7996816223f", 38 | "type": "github" 39 | } 40 | } 41 | }, 42 | "root": "root", 43 | "version": 7 44 | } 45 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 4 | zigpkgs = { 5 | url = "github:NixOS/nixpkgs/592a779f3c5e7bce1a02027abe11b7996816223f"; 6 | }; 7 | }; 8 | 9 | nixConfig = { 10 | extra-substituters = ["https://elixir-tools.cachix.org"]; 11 | extra-trusted-public-keys = ["elixir-tools.cachix.org-1:GfK9E139Ysi+YWeS1oNN9OaTfQjqpLwlBaz+/73tBjU="]; 12 | }; 13 | 14 | outputs = { 15 | self, 16 | nixpkgs, 17 | zigpkgs, 18 | }: let 19 | inherit (nixpkgs) lib; 20 | 21 | # Helper to provide system-specific attributes 22 | forAllSystems = f: 23 | lib.genAttrs systems (system: let 24 | pkgs = nixpkgs.legacyPackages.${system}; 25 | zpkgs = zigpkgs.legacyPackages.${system}; 26 | beamPackages = pkgs.beam_minimal.packages.erlang_27; 27 | elixir = beamPackages.elixir_1_17; 28 | # example of overriding elixir with whatever you want 29 | # elixir = beamPackages.elixir_1_18.override { 30 | # rev = "f16fb5aa8162794616a738fc6e84bfcdf9892cff"; 31 | # sha256 = "sha256-UYWsmih+0z+4tdPhxl2zf+4gUNEgRJR4yyvxVBOgJdQ="; 32 | # }; 33 | in 34 | f {inherit system pkgs zpkgs beamPackages elixir;}); 35 | 36 | systems = [ 37 | "aarch64-darwin" 38 | "x86_64-darwin" 39 | "x86_64-linux" 40 | "aarch64-linux" 41 | ]; 42 | in { 43 | packages = forAllSystems ({ 44 | pkgs, 45 | system, 46 | beamPackages, 47 | elixir, 48 | ... 49 | }: { 50 | default = pkgs.callPackage ./package.nix {inherit beamPackages elixir;}; 51 | }); 52 | 53 | devShells = forAllSystems ({ 54 | pkgs, 55 | zpkgs, 56 | beamPackages, 57 | elixir, 58 | ... 59 | }: let 60 | aliased_7zz = pkgs.symlinkJoin { 61 | name = "7zz-aliased"; 62 | paths = [pkgs._7zz]; 63 | postBuild = '' 64 | ln -s ${pkgs._7zz}/bin/7zz $out/bin/7z 65 | ''; 66 | }; 67 | in { 68 | default = pkgs.mkShell { 69 | # The Nix packages provided in the environment 70 | packages = [ 71 | beamPackages.erlang 72 | elixir 73 | aliased_7zz 74 | pkgs.autoconf 75 | pkgs.just 76 | pkgs.automake 77 | pkgs.ncurses5 78 | pkgs.openssl 79 | pkgs.starship 80 | pkgs.xz 81 | zpkgs.zig_0_11 82 | pkgs.zsh 83 | ]; 84 | }; 85 | }); 86 | }; 87 | } 88 | -------------------------------------------------------------------------------- /grafana-datasources.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | datasources: 4 | - name: Prometheus 5 | type: prometheus 6 | uid: prometheus 7 | access: proxy 8 | orgId: 1 9 | url: http://prometheus:9090 10 | basicAuth: false 11 | isDefault: false 12 | version: 1 13 | editable: false 14 | jsonData: 15 | httpMethod: GET 16 | - name: Tempo 17 | type: tempo 18 | access: proxy 19 | orgId: 1 20 | url: http://tempo:3200 21 | basicAuth: false 22 | isDefault: true 23 | version: 1 24 | editable: false 25 | apiVersion: 1 26 | uid: tempo 27 | jsonData: 28 | httpMethod: GET 29 | serviceMap: 30 | datasourceUid: prometheus 31 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | default: deps compile build-local 2 | 3 | choose: 4 | just --choose 5 | 6 | deps: 7 | mix deps.get 8 | 9 | compile: 10 | mix compile 11 | 12 | start: 13 | bin/start --port 9000 14 | 15 | test: 16 | mix test 17 | 18 | format: 19 | mix format 20 | 21 | lint: 22 | #!/usr/bin/env bash 23 | set -euxo pipefail 24 | 25 | mix format --check-formatted 26 | mix credo 27 | mix dialyzer 28 | 29 | [unix] 30 | build-local: 31 | #!/usr/bin/env bash 32 | case "{{os()}}-{{arch()}}" in 33 | "linux-arm" | "linux-aarch64") 34 | target=linux_arm64;; 35 | "linux-x86" | "linux-x86_64") 36 | target=linux_amd64;; 37 | "macos-arm" | "macos-aarch64") 38 | target=darwin_arm64;; 39 | "macos-x86" | "macos-x86_64") 40 | target=darwin_amd64;; 41 | *) 42 | echo "unsupported OS/Arch combination" 43 | exit 1;; 44 | esac 45 | 46 | NEXTLS_RELEASE_MODE=burrito BURRITO_TARGET="$target" MIX_ENV=prod mix release 47 | 48 | [windows] 49 | build-local: 50 | # idk actually how to set env vars like this on windows, might crash 51 | NEXTLS_RELEASE_MODE=burrito BURRITO_TARGET="windows_amd64" MIX_ENV=prod mix release 52 | 53 | build-all: 54 | NEXTLS_RELEASE_MODE=burrito MIX_ENV=prod mix release 55 | 56 | build-plain: 57 | MIX_ENV=prod mix release plain 58 | 59 | bump-spitfire: 60 | mix deps.update spitfire 61 | -------------------------------------------------------------------------------- /lib/next_ls/aliases.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.Aliases do 2 | @moduledoc false 3 | # necessary evil, just way too many aliases 4 | defmacro __using__(_) do 5 | quote do 6 | alias GenLSP.Enumerations.CodeActionKind 7 | alias GenLSP.Enumerations.CompletionItemKind 8 | alias GenLSP.Enumerations.ErrorCodes 9 | alias GenLSP.Enumerations.FileChangeType 10 | alias GenLSP.Enumerations.MessageType 11 | alias GenLSP.Enumerations.SymbolKind 12 | alias GenLSP.Enumerations.TextDocumentSyncKind 13 | alias GenLSP.ErrorResponse 14 | alias GenLSP.Notifications.Exit 15 | alias GenLSP.Notifications.Initialized 16 | alias GenLSP.Notifications.TextDocumentDidChange 17 | alias GenLSP.Notifications.TextDocumentDidOpen 18 | alias GenLSP.Notifications.TextDocumentDidSave 19 | alias GenLSP.Notifications.WindowShowMessage 20 | alias GenLSP.Notifications.WorkspaceDidChangeWatchedFiles 21 | alias GenLSP.Notifications.WorkspaceDidChangeWorkspaceFolders 22 | alias GenLSP.Requests.Initialize 23 | alias GenLSP.Requests.Shutdown 24 | alias GenLSP.Requests.TextDocumentCodeAction 25 | alias GenLSP.Requests.TextDocumentCompletion 26 | alias GenLSP.Requests.TextDocumentDefinition 27 | alias GenLSP.Requests.TextDocumentDocumentSymbol 28 | alias GenLSP.Requests.TextDocumentFormatting 29 | alias GenLSP.Requests.TextDocumentHover 30 | alias GenLSP.Requests.TextDocumentReferences 31 | alias GenLSP.Requests.WorkspaceApplyEdit 32 | alias GenLSP.Requests.WorkspaceSymbol 33 | alias GenLSP.Structures.ApplyWorkspaceEditParams 34 | alias GenLSP.Structures.CodeActionContext 35 | alias GenLSP.Structures.CodeActionOptions 36 | alias GenLSP.Structures.CodeActionParams 37 | alias GenLSP.Structures.Diagnostic 38 | alias GenLSP.Structures.DidChangeWatchedFilesParams 39 | alias GenLSP.Structures.DidChangeWorkspaceFoldersParams 40 | alias GenLSP.Structures.DidOpenTextDocumentParams 41 | alias GenLSP.Structures.InitializeParams 42 | alias GenLSP.Structures.InitializeResult 43 | alias GenLSP.Structures.Location 44 | alias GenLSP.Structures.MessageActionItem 45 | alias GenLSP.Structures.Position 46 | alias GenLSP.Structures.Range 47 | alias GenLSP.Structures.SaveOptions 48 | alias GenLSP.Structures.ServerCapabilities 49 | alias GenLSP.Structures.ShowMessageParams 50 | alias GenLSP.Structures.SymbolInformation 51 | alias GenLSP.Structures.TextDocumentIdentifier 52 | alias GenLSP.Structures.TextDocumentItem 53 | alias GenLSP.Structures.TextDocumentSyncOptions 54 | alias GenLSP.Structures.TextEdit 55 | alias GenLSP.Structures.WorkspaceEdit 56 | alias GenLSP.Structures.WorkspaceFoldersChangeEvent 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/next_ls/application.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | if Application.get_env(:next_ls, :otel, false) do 11 | NextLS.OpentelemetrySchematic.setup() 12 | NextLS.OpentelemetryGenLSP.setup() 13 | end 14 | 15 | case System.cmd("epmd", ["-daemon"], stderr_to_stdout: true) do 16 | {_, 0} -> 17 | :ok 18 | 19 | {output, code} -> 20 | IO.warn("Failed to start epmd! Exited with code=#{code} and output=#{output}") 21 | 22 | raise "Failed to start epmd!" 23 | end 24 | 25 | Node.start(:"next-ls-#{System.system_time()}", :shortnames) 26 | 27 | children = [NextLS.LSPSupervisor] 28 | 29 | # See https://hexdocs.pm/elixir/Supervisor.html 30 | # for other strategies and supported options 31 | opts = [strategy: :one_for_one, name: NextLS.Supervisor] 32 | Supervisor.start_link(children, opts) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/next_ls/code_actionable.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.CodeActionable do 2 | @moduledoc false 3 | # A diagnostic can produce 1 or more code actions hence we return a list 4 | 5 | alias GenLSP.Structures.CodeAction 6 | alias GenLSP.Structures.Diagnostic 7 | 8 | defmodule Data do 9 | @moduledoc false 10 | defstruct [:diagnostic, :uri, :document] 11 | 12 | @type t :: %__MODULE__{ 13 | diagnostic: Diagnostic.t(), 14 | uri: String.t(), 15 | document: String.t() 16 | } 17 | end 18 | 19 | @callback from(diagnostic :: Data.t()) :: [CodeAction.t()] 20 | 21 | # TODO: Add support for third party extensions 22 | def from("elixir", diagnostic_data) do 23 | NextLS.ElixirExtension.CodeAction.from(diagnostic_data) 24 | end 25 | 26 | def from("credo", diagnostic_data) do 27 | NextLS.CredoExtension.CodeAction.from(diagnostic_data) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/next_ls/commands.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.Commands do 2 | @moduledoc false 3 | 4 | @labels %{ 5 | "from-pipe" => "Inlined pipe", 6 | "to-pipe" => "Extracted to a pipe", 7 | "alias-refactor" => "Refactored with an alias" 8 | } 9 | @doc "Creates a label for the workspace apply struct from the command name" 10 | def label(command) when is_map_key(@labels, command), do: @labels[command] 11 | 12 | def label(command) do 13 | raise ArgumentError, "command #{inspect(command)} not supported" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/next_ls/commands/alias.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.Commands.Alias do 2 | @moduledoc """ 3 | Refactors a module with fully qualified calls to an alias. 4 | The cursor position should be under the module name that you wish to alias. 5 | """ 6 | import Schematic 7 | 8 | alias GenLSP.Enumerations.ErrorCodes 9 | alias GenLSP.Structures.Position 10 | alias GenLSP.Structures.Range 11 | alias GenLSP.Structures.TextEdit 12 | alias GenLSP.Structures.WorkspaceEdit 13 | alias NextLS.ASTHelpers 14 | alias NextLS.EditHelpers 15 | alias Sourceror.Zipper, as: Z 16 | 17 | @line_length 121 18 | 19 | defp opts do 20 | map(%{ 21 | position: Position.schematic(), 22 | uri: str(), 23 | text: list(str()) 24 | }) 25 | end 26 | 27 | def run(opts) do 28 | with {:ok, %{text: text, uri: uri, position: position}} <- unify(opts(), Map.new(opts)), 29 | {:ok, ast, comments} = parse(text), 30 | {:ok, defm} <- ASTHelpers.get_surrounding_module(ast, position), 31 | {:ok, {:__aliases__, _, modules}} <- get_node(ast, position) do 32 | range = make_range(defm) 33 | indent = EditHelpers.get_indent(text, range.start.line) 34 | aliased = get_aliased(defm, modules) 35 | 36 | comments = 37 | Enum.filter(comments, fn comment -> 38 | comment.line > range.start.line && comment.line <= range.end.line 39 | end) 40 | 41 | to_algebra_opts = [comments: comments] 42 | doc = Code.quoted_to_algebra(aliased, to_algebra_opts) 43 | formatted = doc |> Inspect.Algebra.format(@line_length) |> IO.iodata_to_binary() 44 | 45 | %WorkspaceEdit{ 46 | changes: %{ 47 | uri => [ 48 | %TextEdit{ 49 | new_text: 50 | EditHelpers.add_indent_to_edit( 51 | formatted, 52 | indent 53 | ), 54 | range: range 55 | } 56 | ] 57 | } 58 | } 59 | else 60 | {:error, message} -> 61 | %GenLSP.ErrorResponse{code: ErrorCodes.parse_error(), message: inspect(message)} 62 | end 63 | end 64 | 65 | defp parse(lines) do 66 | lines 67 | |> Enum.join("\n") 68 | |> Spitfire.parse_with_comments(literal_encoder: &{:ok, {:__block__, &2, [&1]}}) 69 | |> case do 70 | {:error, ast, comments, _errors} -> 71 | {:ok, ast, comments} 72 | 73 | other -> 74 | other 75 | end 76 | end 77 | 78 | defp make_range(original_ast) do 79 | range = Sourceror.get_range(original_ast) 80 | 81 | %Range{ 82 | start: %Position{line: range.start[:line] - 1, character: range.start[:column] - 1}, 83 | end: %Position{line: range.end[:line] - 1, character: range.end[:column] - 1} 84 | } 85 | end 86 | 87 | def get_node(ast, pos) do 88 | pos = [line: pos.line + 1, column: pos.character + 1] 89 | 90 | result = 91 | ast 92 | |> Z.zip() 93 | |> Z.traverse(nil, fn tree, acc -> 94 | node = Z.node(tree) 95 | range = Sourceror.get_range(node) 96 | 97 | if not is_nil(range) and 98 | match?({:__aliases__, _context, _modules}, node) && 99 | Sourceror.compare_positions(range.start, pos) in [:lt, :eq] && 100 | Sourceror.compare_positions(range.end, pos) in [:gt, :eq] do 101 | {tree, node} 102 | else 103 | {tree, acc} 104 | end 105 | end) 106 | 107 | case result do 108 | {_, nil} -> 109 | {:error, "could not find a module to alias at the cursor position"} 110 | 111 | {_, {_t, _m, []}} -> 112 | {:error, "could not find a module to alias at the cursor position"} 113 | 114 | {_, {_t, _m, [_argument | _rest]} = node} -> 115 | {:ok, node} 116 | end 117 | end 118 | 119 | defp get_aliased(defm, modules) do 120 | last = List.last(modules) 121 | 122 | replaced = 123 | Macro.prewalk(defm, fn 124 | {:__aliases__, context, ^modules} -> {:__aliases__, context, [last]} 125 | ast -> ast 126 | end) 127 | 128 | alias_to_add = {:alias, [alias: false], [{:__aliases__, [], modules}]} 129 | 130 | {:defmodule, context, [module, [{do_block, block}]]} = replaced 131 | 132 | case block do 133 | {:__block__, block_context, defs} -> 134 | {:defmodule, context, [module, [{do_block, {:__block__, block_context, [alias_to_add | defs]}}]]} 135 | 136 | {_, _, _} = original -> 137 | {:defmodule, context, [module, [{do_block, {:__block__, [], [alias_to_add, original]}}]]} 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /lib/next_ls/commands/pipe.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.Commands.Pipe do 2 | @moduledoc false 3 | import Schematic 4 | 5 | alias GenLSP.Enumerations.ErrorCodes 6 | alias GenLSP.Structures.Position 7 | alias GenLSP.Structures.Range 8 | alias GenLSP.Structures.TextEdit 9 | alias GenLSP.Structures.WorkspaceEdit 10 | alias NextLS.EditHelpers 11 | alias Sourceror.Zipper, as: Z 12 | 13 | defp opts do 14 | map(%{ 15 | position: Position.schematic(), 16 | uri: str(), 17 | text: list(str()) 18 | }) 19 | end 20 | 21 | def to(opts) do 22 | with {:ok, %{text: text, uri: uri, position: position}} <- unify(opts(), Map.new(opts)), 23 | {:ok, ast} = parse(text), 24 | {:ok, {t, m, [argument | rest]} = original} <- get_node(ast, position) do 25 | range = make_range(original) 26 | indent = EditHelpers.get_indent(text, range.start.line) 27 | piped = {:|>, [], [argument, {t, m, rest}]} 28 | 29 | %WorkspaceEdit{ 30 | changes: %{ 31 | uri => [ 32 | %TextEdit{ 33 | new_text: 34 | EditHelpers.add_indent_to_edit( 35 | Macro.to_string(piped), 36 | indent 37 | ), 38 | range: range 39 | } 40 | ] 41 | } 42 | } 43 | else 44 | {:error, message} -> 45 | %GenLSP.ErrorResponse{code: ErrorCodes.parse_error(), message: inspect(message)} 46 | end 47 | end 48 | 49 | def from(opts) do 50 | with {:ok, %{text: text, uri: uri, position: position}} <- unify(opts(), Map.new(opts)), 51 | {:ok, ast} = parse(text), 52 | {:ok, {:|>, _m, [left, {right, _, args}]} = original} <- get_pipe_node(ast, position) do 53 | range = make_range(original) 54 | indent = EditHelpers.get_indent(text, range.start.line) 55 | unpiped = {right, [], [left | args]} 56 | 57 | %WorkspaceEdit{ 58 | changes: %{ 59 | uri => [ 60 | %TextEdit{ 61 | new_text: 62 | EditHelpers.add_indent_to_edit( 63 | Macro.to_string(unpiped), 64 | indent 65 | ), 66 | range: range 67 | } 68 | ] 69 | } 70 | } 71 | else 72 | {:error, message} -> 73 | %GenLSP.ErrorResponse{code: ErrorCodes.parse_error(), message: inspect(message)} 74 | end 75 | end 76 | 77 | defp parse(lines) do 78 | lines 79 | |> Enum.join("\n") 80 | |> Spitfire.parse(literal_encoder: &{:ok, {:__block__, &2, [&1]}}) 81 | |> case do 82 | {:error, ast, _errors} -> 83 | {:ok, ast} 84 | 85 | other -> 86 | other 87 | end 88 | end 89 | 90 | defp make_range(original_ast) do 91 | range = Sourceror.get_range(original_ast) 92 | 93 | %Range{ 94 | start: %Position{line: range.start[:line] - 1, character: range.start[:column] - 1}, 95 | end: %Position{line: range.end[:line] - 1, character: range.end[:column] - 1} 96 | } 97 | end 98 | 99 | def get_node(ast, pos) do 100 | pos = [line: pos.line + 1, column: pos.character + 1] 101 | 102 | result = 103 | ast 104 | |> Z.zip() 105 | |> Z.traverse(nil, fn tree, acc -> 106 | node = Z.node(tree) 107 | range = Sourceror.get_range(node) 108 | 109 | if not is_nil(range) and 110 | (match?({{:., _, _}, _, [_ | _]}, node) or 111 | match?({t, _, [_ | _]} when t not in [:., :__aliases__, :__block__, :=], node)) do 112 | if Sourceror.compare_positions(range.start, pos) == :lt && 113 | Sourceror.compare_positions(range.end, pos) == :gt do 114 | {tree, node} 115 | else 116 | {tree, acc} 117 | end 118 | else 119 | {tree, acc} 120 | end 121 | end) 122 | 123 | case result do 124 | {_, nil} -> 125 | {:error, "could not find an argument to extract at the cursor position"} 126 | 127 | {_, {_t, _m, []}} -> 128 | {:error, "could not find an argument to extract at the cursor position"} 129 | 130 | {_, {_t, _m, [_argument | _rest]} = node} -> 131 | {:ok, node} 132 | end 133 | end 134 | 135 | def get_pipe_node(ast, pos) do 136 | pos = [line: pos.line + 1, column: pos.character + 1] 137 | 138 | result = 139 | ast 140 | |> Z.zip() 141 | |> Z.traverse(nil, fn tree, acc -> 142 | node = Z.node(tree) 143 | range = Sourceror.get_range(node) 144 | 145 | if not is_nil(range) and match?({:|>, _, _}, node) do 146 | if Sourceror.compare_positions(range.start, pos) == :lt && 147 | Sourceror.compare_positions(range.end, pos) == :gt do 148 | {tree, node} 149 | else 150 | {tree, acc} 151 | end 152 | else 153 | {tree, acc} 154 | end 155 | end) 156 | 157 | case result do 158 | {_, nil} -> 159 | {:error, "could not find a pipe operator at the cursor position"} 160 | 161 | {_, {_t, _m, [_argument | _rest]} = node} -> 162 | {:ok, node} 163 | end 164 | end 165 | end 166 | -------------------------------------------------------------------------------- /lib/next_ls/db.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.DB do 2 | @moduledoc nil 3 | use GenServer 4 | 5 | import __MODULE__.Query 6 | 7 | alias NextLS.DB.Activity 8 | alias OpenTelemetry.Tracer 9 | 10 | require OpenTelemetry.Tracer 11 | 12 | @type query :: String.t() 13 | 14 | def start_link(args) do 15 | GenServer.start_link(__MODULE__, args, Keyword.take(args, [:name])) 16 | end 17 | 18 | @spec query(pid(), query(), list()) :: list() 19 | def query(server, query, opts \\ []) do 20 | ctx = OpenTelemetry.Ctx.get_current() 21 | GenServer.call(server, {:query, query, opts, ctx}, :infinity) 22 | end 23 | 24 | @spec insert_symbol(pid(), map()) :: :ok 25 | def insert_symbol(server, payload), do: GenServer.cast(server, {:insert_symbol, payload}) 26 | 27 | @spec insert_reference(pid(), map()) :: :ok 28 | def insert_reference(server, payload), do: GenServer.cast(server, {:insert_reference, payload}) 29 | 30 | @spec clean_references(pid(), String.t()) :: :ok 31 | def clean_references(server, filename), do: GenServer.cast(server, {:clean_references, filename}) 32 | 33 | def init(args) do 34 | file = Keyword.fetch!(args, :file) 35 | registry = Keyword.fetch!(args, :registry) 36 | logger = Keyword.fetch!(args, :logger) 37 | activity = Keyword.fetch!(args, :activity) 38 | runtime = Keyword.fetch!(args, :runtime) 39 | 40 | {:ok, conn} = Exqlite.Basic.open(file) 41 | {:ok, mode} = NextLS.DB.Schema.init({conn, logger}) 42 | 43 | Registry.register(registry, :databases, %{mode: mode, runtime: runtime}) 44 | 45 | {:ok, 46 | %{ 47 | conn: conn, 48 | file: file, 49 | logger: logger, 50 | activity: activity 51 | }} 52 | end 53 | 54 | def handle_call({:query, query, args_or_opts, ctx}, _from, %{conn: conn} = s) do 55 | token = OpenTelemetry.Ctx.attach(ctx) 56 | 57 | try do 58 | Tracer.with_span :"db.query receive", %{attributes: %{query: query}} do 59 | {:message_queue_len, count} = Process.info(self(), :message_queue_len) 60 | Activity.update(s.activity, count) 61 | opts = if Keyword.keyword?(args_or_opts), do: args_or_opts, else: [args: args_or_opts] 62 | 63 | query = 64 | if opts[:select] do 65 | String.replace(query, ":select", Enum.map_join(opts[:select], ", ", &to_string/1)) 66 | else 67 | query 68 | end 69 | 70 | rows = 71 | for row <- __query__({conn, s.logger}, query, opts[:args] || []) do 72 | if opts[:select] do 73 | opts[:select] |> Enum.zip(row) |> Map.new() 74 | else 75 | row 76 | end 77 | end 78 | 79 | {:reply, rows, s} 80 | end 81 | after 82 | OpenTelemetry.Ctx.detach(token) 83 | end 84 | end 85 | 86 | def handle_cast({:insert_symbol, symbol}, %{conn: conn} = s) do 87 | {:message_queue_len, count} = Process.info(self(), :message_queue_len) 88 | Activity.update(s.activity, count) 89 | 90 | %{ 91 | module: mod, 92 | module_line: module_line, 93 | struct: struct, 94 | file: file, 95 | defs: defs, 96 | symbols: symbols, 97 | source: source 98 | } = symbol 99 | 100 | __query__( 101 | {conn, s.logger}, 102 | ~Q""" 103 | DELETE FROM symbols 104 | WHERE module = ?; 105 | """, 106 | [mod] 107 | ) 108 | 109 | __query__( 110 | {conn, s.logger}, 111 | ~Q""" 112 | INSERT INTO symbols (module, file, type, name, line, 'column', 'end_column', source) 113 | VALUES (?, ?, ?, ?, ?, ?, ?, ?); 114 | """, 115 | [mod, file, "defmodule", mod, module_line, 1, String.length(Macro.to_string(mod)), source] 116 | ) 117 | 118 | if struct do 119 | {_, _, meta, _} = defs[:__struct__] 120 | 121 | __query__( 122 | {conn, s.logger}, 123 | ~Q""" 124 | INSERT INTO symbols (module, file, type, name, line, 'column', 'end_column', source) 125 | VALUES (?, ?, ?, ?, ?, ?, ?, ?); 126 | """, 127 | [ 128 | mod, 129 | file, 130 | "defstruct", 131 | "%#{Macro.to_string(mod)}{}", 132 | meta[:line], 133 | meta[:column] || 1, 134 | meta[:column] || 1, 135 | source 136 | ] 137 | ) 138 | end 139 | 140 | for {name, {:v1, type, _meta, clauses}} <- defs, {meta, params, _, _} <- clauses do 141 | __query__( 142 | {conn, s.logger}, 143 | ~Q""" 144 | INSERT INTO symbols (module, file, type, name, params, line, 'column', end_column, source) 145 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); 146 | """, 147 | [ 148 | mod, 149 | file, 150 | type, 151 | name, 152 | :erlang.term_to_binary(params), 153 | meta[:line], 154 | meta[:column] || 1, 155 | (meta[:column] || 1) + String.length(to_string(name)) - 1, 156 | source 157 | ] 158 | ) 159 | end 160 | 161 | for {type, name, line, column} <- symbols do 162 | __query__( 163 | {conn, s.logger}, 164 | ~Q""" 165 | INSERT INTO symbols (module, file, type, name, line, 'column', 'end_column', source) 166 | VALUES (?, ?, ?, ?, ?, ?, ?, ?); 167 | """, 168 | [mod, file, type, name, line, column, column + String.length(to_string(name)) - 1, source] 169 | ) 170 | end 171 | 172 | {:noreply, s} 173 | end 174 | 175 | def handle_cast({:insert_reference, reference}, %{conn: conn} = s) do 176 | {:message_queue_len, count} = Process.info(self(), :message_queue_len) 177 | Activity.update(s.activity, count) 178 | 179 | %{ 180 | meta: meta, 181 | identifier: identifier, 182 | file: file, 183 | type: type, 184 | module: module, 185 | source: source 186 | } = reference 187 | 188 | if (meta[:line] && meta[:column]) || (reference[:range][:start] && reference[:range][:stop]) do 189 | line = meta[:line] || 1 190 | col = meta[:column] || 0 191 | 192 | {start_line, start_column} = reference[:range][:start] || {line, col} 193 | 194 | {end_line, end_column} = 195 | reference[:range][:stop] || 196 | {line, col + String.length(identifier |> to_string() |> String.replace("Elixir.", "")) - 1} 197 | 198 | __query__( 199 | {conn, s.logger}, 200 | ~Q""" 201 | INSERT INTO 'references' (identifier, arity, file, type, module, start_line, start_column, end_line, end_column, source) 202 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); 203 | """, 204 | [identifier, reference[:arity], file, type, module, start_line, start_column, end_line, end_column, source] 205 | ) 206 | end 207 | 208 | {:noreply, s} 209 | end 210 | 211 | def handle_cast({:clean_references, filename}, %{conn: conn} = s) do 212 | {:message_queue_len, count} = Process.info(self(), :message_queue_len) 213 | Activity.update(s.activity, count) 214 | 215 | __query__( 216 | {conn, s.logger}, 217 | ~Q""" 218 | DELETE FROM 'references' 219 | WHERE file = ?; 220 | """, 221 | [filename] 222 | ) 223 | 224 | {:noreply, s} 225 | end 226 | 227 | def __query__({conn, logger}, query, args) do 228 | Tracer.with_span :"db.query process", %{attributes: %{query: query}} do 229 | args = Enum.map(args, &cast/1) 230 | 231 | case Exqlite.Basic.exec(conn, query, args) do 232 | {:error, %{message: message, statement: statement}, _} -> 233 | NextLS.Logger.warning(logger, """ 234 | sqlite3 error: #{message} 235 | 236 | statement: #{statement} 237 | arguments: #{inspect(args)} 238 | """) 239 | 240 | {:error, message} 241 | 242 | result -> 243 | {:ok, rows, _} = Exqlite.Basic.rows(result) 244 | rows 245 | end 246 | end 247 | end 248 | 249 | defp cast(arg) do 250 | if is_atom(arg) and String.starts_with?(to_string(arg), "Elixir.") do 251 | Macro.to_string(arg) 252 | else 253 | arg 254 | end 255 | end 256 | end 257 | -------------------------------------------------------------------------------- /lib/next_ls/db/activity.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.DB.Activity do 2 | @moduledoc false 3 | @behaviour :gen_statem 4 | 5 | def child_spec(opts) do 6 | %{ 7 | id: opts[:name] || opts[:id], 8 | start: {__MODULE__, :start_link, [opts]} 9 | } 10 | end 11 | 12 | def start_link(args) do 13 | :gen_statem.start_link({:local, Keyword.get(args, :name)}, __MODULE__, Keyword.delete(args, :name), []) 14 | end 15 | 16 | def update(statem, count), do: :gen_statem.cast(statem, count) 17 | 18 | @impl :gen_statem 19 | def callback_mode, do: :state_functions 20 | 21 | @impl :gen_statem 22 | def init(args) do 23 | logger = Keyword.fetch!(args, :logger) 24 | lsp = Keyword.fetch!(args, :lsp) 25 | timeout = Keyword.fetch!(args, :timeout) 26 | 27 | {:ok, :waiting, %{count: 0, logger: logger, lsp: lsp, timeout: timeout, token: nil}} 28 | end 29 | 30 | def active(:cast, 0, data) do 31 | {:keep_state, %{data | count: 0}, [{:state_timeout, data.timeout, :waiting}]} 32 | end 33 | 34 | def active(:cast, mailbox_count, %{count: 0} = data) do 35 | {:keep_state, %{data | count: mailbox_count}, [{:state_timeout, :cancel}]} 36 | end 37 | 38 | def active(:cast, mailbox_count, data) do 39 | {:keep_state, %{data | count: mailbox_count}, []} 40 | end 41 | 42 | def active(:state_timeout, :waiting, data) do 43 | NextLS.Progress.stop(data.lsp, data.token, "Finished indexing!") 44 | {:next_state, :waiting, %{data | token: nil}} 45 | end 46 | 47 | def waiting(:cast, 0, _data) do 48 | :keep_state_and_data 49 | end 50 | 51 | def waiting(:cast, mailbox_count, data) do 52 | token = NextLS.Progress.token() 53 | NextLS.Progress.start(data.lsp, token, "Indexing!") 54 | {:next_state, :active, %{data | count: mailbox_count, token: token}} 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/next_ls/db/format.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.DB.Format do 2 | @moduledoc false 3 | # @behaviour Mix.Tasks.Format 4 | 5 | # @impl Mix.Tasks.Format 6 | # def features(_opts), do: [sigils: [:Q], extensions: []] 7 | 8 | # @impl Mix.Tasks.Format 9 | # def format(input, _formatter_opts, _opts \\ []) do 10 | # path = Path.join(System.tmp_dir!(), "#{System.unique_integer()}-temp.sql") 11 | # File.write!(path, input) 12 | # {result, 0} = System.cmd("pg_format", [path]) 13 | 14 | # File.rm!(path) 15 | 16 | # String.trim(result) <> "\n" 17 | # end 18 | end 19 | -------------------------------------------------------------------------------- /lib/next_ls/db/query.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.DB.Query do 2 | @moduledoc false 3 | defmacro sigil_Q({:<<>>, _, [bin]}, _mods) do 4 | bin 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/next_ls/db/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.DB.Schema do 2 | @moduledoc """ 3 | The Sqlite3 database schema. 4 | 5 | First, you are probably asking yourself, why doesn't this use Ecto? 6 | 7 | Well, because I didn't want to. And also because I am attempting to restrict this 8 | project to as few dependencies as possible. 9 | 10 | The Ecto migration system is meant for highly durable data, that can't and shouldn't be lost, 11 | whereas the data here is more like a fast and efficient cache. 12 | 13 | Rather than coming up with convoluted data migration strategies, we follow the following algorithm. 14 | 15 | 1. Create the `schema` table if needed. This includes a version column. 16 | 2. If the max version selected from the `schema` table is equal to the current version, we noop and halt. 17 | Else, if the max version is less than the current one (compiled into this module) or nil 18 | we "upgrade" the database. 19 | 3. Unless halting, we drop the non-meta tables, and then create them from scratch 20 | 4. Return a value to signal to the caller that re-indexing is necessary. 21 | """ 22 | import NextLS.DB.Query 23 | 24 | alias NextLS.DB 25 | 26 | @version 7 27 | 28 | def init(conn) do 29 | # FIXME: this is odd tech debt. not a big deal but is confusing 30 | {_, logger} = conn 31 | 32 | NextLS.Logger.info(logger, "Beginning DB migration...") 33 | 34 | DB.__query__( 35 | conn, 36 | ~Q""" 37 | CREATE TABLE IF NOT EXISTS schema ( 38 | id integer PRIMARY KEY, 39 | version integer NOT NULL DEFAULT 1, 40 | inserted_at text NOT NULL DEFAULT CURRENT_TIMESTAMP 41 | ); 42 | """, 43 | [] 44 | ) 45 | 46 | DB.__query__( 47 | conn, 48 | ~Q""" 49 | PRAGMA synchronous = OFF 50 | """, 51 | [] 52 | ) 53 | 54 | result = 55 | case DB.__query__(conn, ~Q"SELECT MAX(version) FROM schema;", []) do 56 | [[version]] when version == @version -> 57 | NextLS.Logger.info(logger, "Database is on the latest version: #{@version}") 58 | {:ok, :noop} 59 | 60 | result -> 61 | version = with([[version]] <- result, do: version) || 0 62 | 63 | NextLS.Logger.info(logger, """ 64 | Database is being upgraded from version #{version} to #{@version}. 65 | 66 | This will trigger a full recompilation in order to re-index your codebase. 67 | """) 68 | 69 | DB.__query__(conn, ~Q"INSERT INTO schema (version) VALUES (?);", [@version]) 70 | 71 | DB.__query__(conn, ~Q"DROP TABLE IF EXISTS symbols;", []) 72 | DB.__query__(conn, ~Q"DROP TABLE IF EXISTS 'references';", []) 73 | 74 | DB.__query__( 75 | conn, 76 | ~Q""" 77 | CREATE TABLE IF NOT EXISTS symbols ( 78 | id integer PRIMARY KEY, 79 | module text NOT NULL, 80 | file text NOT NULL, 81 | type text NOT NULL, 82 | name text NOT NULL, 83 | params blob, 84 | line integer NOT NULL, 85 | column integer NOT NULL, 86 | end_column integer NOT NULL, 87 | source text NOT NULL DEFAULT 'user', 88 | inserted_at text NOT NULL DEFAULT CURRENT_TIMESTAMP 89 | ); 90 | """, 91 | [] 92 | ) 93 | 94 | DB.__query__( 95 | conn, 96 | ~Q""" 97 | CREATE TABLE IF NOT EXISTS 'references' ( 98 | id integer PRIMARY KEY, 99 | identifier text NOT NULL, 100 | arity integer, 101 | file text NOT NULL, 102 | type text NOT NULL, 103 | module text NOT NULL, 104 | start_line integer NOT NULL, 105 | start_column integer NOT NULL, 106 | end_line integer NOT NULL, 107 | end_column integer NOT NULL, 108 | source text NOT NULL DEFAULT 'user', 109 | inserted_at text NOT NULL DEFAULT CURRENT_TIMESTAMP 110 | ) 111 | """, 112 | [] 113 | ) 114 | 115 | {:ok, :reindex} 116 | end 117 | 118 | NextLS.Logger.info(logger, "Finished DB migration...") 119 | result 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/next_ls/definition.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.Definition do 2 | @moduledoc false 3 | import NextLS.DB.Query 4 | 5 | alias NextLS.DB 6 | 7 | def fetch(file, {line, col}, db) do 8 | rows = 9 | DB.query( 10 | db, 11 | ~Q""" 12 | SELECT 13 | * 14 | FROM 15 | 'references' AS refs 16 | WHERE 17 | refs.file = ? 18 | AND refs.start_line <= ? 19 | AND ? <= refs.end_line 20 | AND refs.start_column <= ? 21 | AND ? <= refs.end_column 22 | ORDER BY 23 | (CASE refs.type 24 | WHEN 'function' THEN 0 25 | WHEN 'module' THEN 1 26 | ELSE 2 27 | END) asc 28 | LIMIT 1; 29 | """, 30 | [file, line, line, col, col] 31 | ) 32 | 33 | reference = 34 | case rows do 35 | [[_pk, identifier, _arity, _file, type, module, _start_l, _start_c, _end_l, _end_c | _]] -> 36 | %{identifier: identifier, type: type, module: module} 37 | 38 | [] -> 39 | nil 40 | end 41 | 42 | with %{identifier: identifier, type: type, module: module} <- reference do 43 | query = 44 | ~Q""" 45 | SELECT 46 | * 47 | FROM 48 | symbols 49 | WHERE 50 | symbols.module = ? 51 | AND symbols.name = ?; 52 | """ 53 | 54 | args = 55 | case type do 56 | "alias" -> 57 | [module, module] 58 | 59 | "function" -> 60 | [module, identifier] 61 | 62 | "attribute" -> 63 | [module, identifier] 64 | 65 | _ -> 66 | nil 67 | end 68 | 69 | if args do 70 | DB.query(db, query, args) 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/next_ls/diagnostic_cache.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.DiagnosticCache do 2 | # TODO: this should be an ETS table 3 | @moduledoc false 4 | use Agent 5 | 6 | def start_link(opts) do 7 | Agent.start_link(fn -> Map.new() end, Keyword.take(opts, [:name])) 8 | end 9 | 10 | def get(cache) do 11 | Agent.get(cache, & &1) 12 | end 13 | 14 | def put(cache, namespace, filename, diagnostic) do 15 | Agent.update(cache, fn cache -> 16 | Map.update(cache, namespace, %{filename => [diagnostic]}, fn cache -> 17 | Map.update(cache, filename, [diagnostic], fn v -> 18 | [diagnostic | v] 19 | end) 20 | end) 21 | end) 22 | end 23 | 24 | def clear(cache, namespace) do 25 | Agent.update(cache, fn cache -> 26 | Map.update(cache, namespace, %{}, fn cache -> 27 | for {k, _} <- cache, into: Map.new() do 28 | {k, []} 29 | end 30 | end) 31 | end) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/next_ls/docs.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.Docs do 2 | @moduledoc false 3 | 4 | defstruct module: nil, mdoc: nil, functions: [], content_type: nil 5 | 6 | def new({:docs_v1, _, _lang, content_type, mdoc, _, fdocs}, module) do 7 | mdoc = 8 | case mdoc do 9 | %{"en" => mdoc} -> mdoc 10 | _ -> nil 11 | end 12 | 13 | %__MODULE__{ 14 | content_type: content_type, 15 | module: module, 16 | mdoc: mdoc, 17 | functions: fdocs 18 | } 19 | end 20 | 21 | def new(_, _) do 22 | nil 23 | end 24 | 25 | def module(%__MODULE__{} = doc) do 26 | """ 27 | ## #{Macro.to_string(doc.module)} 28 | 29 | #{to_markdown(doc.content_type, doc.mdoc)} 30 | """ 31 | end 32 | 33 | def function(%__MODULE__{} = doc, callback) do 34 | result = 35 | Enum.find(doc.functions, fn {{type, name, arity}, _some_number, _signature, doc, other} -> 36 | type in [:function, :macro] and callback.(name, arity, doc, other) 37 | end) 38 | 39 | case result do 40 | {{_, name, arity}, _some_number, signature, %{"en" => fdoc}, _other} -> 41 | """ 42 | ## #{Macro.to_string(doc.module)}.#{name}/#{arity} 43 | 44 | `#{signature}` 45 | 46 | #{to_markdown(doc.content_type, fdoc)} 47 | """ 48 | 49 | _ -> 50 | nil 51 | end 52 | end 53 | 54 | @spec to_markdown(String.t(), String.t() | list()) :: String.t() 55 | def to_markdown(type, docs) 56 | def to_markdown("text/markdown", docs), do: docs 57 | 58 | def to_markdown("application/erlang+html" = type, [{:p, _, children} | rest]) do 59 | String.trim(to_markdown(type, children) <> "\n\n" <> to_markdown(type, rest)) 60 | end 61 | 62 | def to_markdown("application/erlang+html" = type, [{:div, attrs, children} | rest]) do 63 | prefix = 64 | if attrs[:class] in ~w do 65 | "> " 66 | else 67 | "" 68 | end 69 | 70 | prefix <> to_markdown(type, children) <> "\n\n" <> to_markdown(type, rest) 71 | end 72 | 73 | def to_markdown("application/erlang+html" = type, [{:a, attrs, children} | rest]) do 74 | space = if List.last(children) == " ", do: " ", else: "" 75 | 76 | "[#{String.trim(to_markdown(type, children))}](#{attrs[:href]})" <> space <> to_markdown(type, rest) 77 | end 78 | 79 | def to_markdown("application/erlang+html" = type, [doc | rest]) when is_binary(doc) do 80 | doc <> to_markdown(type, rest) 81 | end 82 | 83 | def to_markdown("application/erlang+html" = type, [{:h1, _, children} | rest]) do 84 | "# #{to_markdown(type, children)}\n" <> to_markdown(type, rest) 85 | end 86 | 87 | def to_markdown("application/erlang+html" = type, [{:h2, _, children} | rest]) do 88 | "## #{to_markdown(type, children)}\n" <> to_markdown(type, rest) 89 | end 90 | 91 | def to_markdown("application/erlang+html" = type, [{:h3, _, children} | rest]) do 92 | "### #{to_markdown(type, children)}\n" <> to_markdown(type, rest) 93 | end 94 | 95 | def to_markdown("application/erlang+html" = type, [{:h4, _, children} | rest]) do 96 | "#### #{to_markdown(type, children)}\n" <> to_markdown(type, rest) 97 | end 98 | 99 | def to_markdown("application/erlang+html" = type, [{:h5, _, children} | rest]) do 100 | "##### #{to_markdown(type, children)}\n" <> to_markdown(type, rest) 101 | end 102 | 103 | def to_markdown("application/erlang+html" = type, [{:pre, _, [{:code, _, children}]} | rest]) do 104 | "```erlang\n#{to_markdown(type, children)}\n```\n\n" <> to_markdown(type, rest) 105 | end 106 | 107 | def to_markdown("application/erlang+html" = type, [{:ul, [class: "types"], lis} | rest]) do 108 | "### Types\n\n#{to_markdown(type, lis)}\n" <> to_markdown(type, rest) 109 | end 110 | 111 | def to_markdown("application/erlang+html" = type, [{:ul, _, lis} | rest]) do 112 | "#{to_markdown(type, lis)}\n" <> to_markdown(type, rest) 113 | end 114 | 115 | def to_markdown("application/erlang+html" = type, [{:li, [name: text], _} | rest]) do 116 | "* #{text}\n" <> to_markdown(type, rest) 117 | end 118 | 119 | def to_markdown("application/erlang+html" = type, [{:li, _, children} | rest]) do 120 | "* #{to_markdown(type, children)}\n" <> to_markdown(type, rest) 121 | end 122 | 123 | def to_markdown("application/erlang+html" = type, [{:code, _, bins} | rest]) do 124 | "`#{IO.iodata_to_binary(bins)}`" <> to_markdown(type, rest) 125 | end 126 | 127 | def to_markdown("application/erlang+html" = type, [{:em, _, bins} | rest]) do 128 | "_#{IO.iodata_to_binary(bins)}_" <> to_markdown(type, rest) 129 | end 130 | 131 | def to_markdown("application/erlang+html" = type, [{:dl, _, lis} | rest]) do 132 | "#{to_markdown(type, lis)}\n" <> to_markdown(type, rest) 133 | end 134 | 135 | def to_markdown("application/erlang+html" = type, [{:dt, _, children} | rest]) do 136 | "* #{to_markdown(type, children)}\n" <> to_markdown(type, rest) 137 | end 138 | 139 | def to_markdown("application/erlang+html" = type, [{:dd, _, children} | rest]) do 140 | "#{to_markdown(type, children)}\n" <> to_markdown(type, rest) 141 | end 142 | 143 | def to_markdown("application/erlang+html" = type, [{:ol, _, lis} | rest]) do 144 | "#{to_markdown(type, lis)}\n" <> to_markdown(type, rest) 145 | end 146 | 147 | def to_markdown("application/erlang+html" = type, [{:ot, _, children} | rest]) do 148 | "1. #{to_markdown(type, children)}\n" <> to_markdown(type, rest) 149 | end 150 | 151 | def to_markdown("application/erlang+html" = type, [{:i, _, children} | rest]) do 152 | "_#{IO.iodata_to_binary(children)}_" <> to_markdown(type, rest) 153 | end 154 | 155 | def to_markdown("application/erlang+html", []) do 156 | "" 157 | end 158 | 159 | def to_markdown("application/erlang+html", nil) do 160 | "" 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /lib/next_ls/document_symbol.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.DocumentSymbol do 2 | @moduledoc false 3 | 4 | alias GenLSP.Enumerations.SymbolKind 5 | alias GenLSP.Structures.DocumentSymbol 6 | alias GenLSP.Structures.Position 7 | alias GenLSP.Structures.Range 8 | 9 | @spec fetch(text :: String.t()) :: list(DocumentSymbol.t()) 10 | def fetch(text) do 11 | ast = 12 | case NextLS.Parser.parse( 13 | text, 14 | # we set the literal encoder so that we can know when atoms and strings start and end 15 | # this makes it useful for knowing the exact locations of struct field definitions 16 | literal_encoder: fn literal, meta -> 17 | if is_atom(literal) or is_binary(literal) do 18 | {:ok, {:__literal__, meta, [literal]}} 19 | else 20 | {:ok, literal} 21 | end 22 | end, 23 | unescape: false, 24 | token_metadata: true, 25 | columns: true 26 | ) do 27 | {:error, ast, _errors} -> 28 | ast 29 | 30 | {:error, _} -> 31 | raise "Failed to parse!" 32 | 33 | {:ok, ast} -> 34 | ast 35 | end 36 | 37 | for %DocumentSymbol{} = ds <- List.wrap(walker(ast, nil)) do 38 | ds 39 | end 40 | end 41 | 42 | defp walker([{{:__literal__, _, [:do]}, {_, _, _exprs} = ast}], mod) do 43 | walker(ast, mod) 44 | end 45 | 46 | defp walker({:__block__, _, exprs}, mod) do 47 | for expr <- exprs, sym = walker(expr, mod), sym != nil do 48 | sym 49 | end 50 | end 51 | 52 | defp walker({:defmodule, meta, [name | children]}, _mod) do 53 | name = Macro.to_string(unliteral(name)) 54 | 55 | %DocumentSymbol{ 56 | name: name, 57 | kind: SymbolKind.module(), 58 | children: List.flatten(for(child <- children, sym = walker(child, name), sym != nil, do: sym)), 59 | range: %Range{ 60 | start: %Position{line: meta[:line] - 1, character: meta[:column] - 1}, 61 | end: %Position{line: meta[:end][:line] - 1, character: meta[:end][:column] - 1} 62 | }, 63 | selection_range: %Range{ 64 | start: %Position{line: meta[:line] - 1, character: meta[:column] - 1}, 65 | end: %Position{line: meta[:line] - 1, character: meta[:column] - 1} 66 | } 67 | } 68 | end 69 | 70 | defp walker({:describe, meta, [name | children]}, mod) do 71 | name = String.replace("describe " <> Macro.to_string(unliteral(name)), "\n", "") 72 | 73 | %DocumentSymbol{ 74 | name: name, 75 | kind: SymbolKind.class(), 76 | children: List.flatten(for(child <- children, sym = walker(child, mod), sym != nil, do: sym)), 77 | range: %Range{ 78 | start: %Position{line: meta[:line] - 1, character: meta[:column] - 1}, 79 | end: %Position{line: meta[:end][:line] - 1, character: meta[:end][:column] - 1} 80 | }, 81 | selection_range: %Range{ 82 | start: %Position{line: meta[:line] - 1, character: meta[:column] - 1}, 83 | end: %Position{line: meta[:line] - 1, character: meta[:column] - 1} 84 | } 85 | } 86 | end 87 | 88 | defp walker({:defstruct, meta, [fields]}, mod) do 89 | fields = 90 | for field <- fields do 91 | {name, start_line, start_column} = 92 | case field do 93 | {:__literal__, meta, [name]} -> 94 | start_line = meta[:line] - 1 95 | start_column = meta[:column] - 1 96 | name = Macro.to_string(name) 97 | 98 | {name, start_line, start_column} 99 | 100 | {{:__literal__, meta, [name]}, default} -> 101 | start_line = meta[:line] - 1 102 | start_column = meta[:column] - 1 103 | name = to_string(name) <> ": " <> Macro.to_string(unliteral(default)) 104 | 105 | {name, start_line, start_column} 106 | end 107 | 108 | %DocumentSymbol{ 109 | name: name, 110 | children: [], 111 | kind: SymbolKind.field(), 112 | range: %Range{ 113 | start: %Position{ 114 | line: start_line, 115 | character: start_column 116 | }, 117 | end: %Position{ 118 | line: start_line, 119 | character: start_column + String.length(name) 120 | } 121 | }, 122 | selection_range: %Range{ 123 | start: %Position{line: start_line, character: start_column}, 124 | end: %Position{line: start_line, character: start_column} 125 | } 126 | } 127 | end 128 | 129 | %DocumentSymbol{ 130 | name: "%#{mod}{}", 131 | children: fields, 132 | kind: elixir_kind_to_lsp_kind(:defstruct), 133 | range: %Range{ 134 | start: %Position{ 135 | line: meta[:line] - 1, 136 | character: meta[:column] - 1 137 | }, 138 | end: %Position{ 139 | line: (meta[:end_of_expression][:line] || meta[:line]) - 1, 140 | character: (meta[:end_of_expression][:column] || meta[:column]) - 1 141 | } 142 | }, 143 | selection_range: %Range{ 144 | start: %Position{line: meta[:line] - 1, character: meta[:column] - 1}, 145 | end: %Position{line: meta[:line] - 1, character: meta[:column] - 1} 146 | } 147 | } 148 | end 149 | 150 | defp walker({:@, meta, [{_name, _, value}]} = attribute, _) when length(value) > 0 do 151 | %DocumentSymbol{ 152 | name: attribute |> unliteral() |> Macro.to_string() |> String.replace("\n", ""), 153 | children: [], 154 | kind: elixir_kind_to_lsp_kind(:@), 155 | range: %Range{ 156 | start: %Position{ 157 | line: meta[:line] - 1, 158 | character: meta[:column] - 1 159 | }, 160 | end: %Position{ 161 | line: (meta[:end_of_expression] || meta)[:line] - 1, 162 | character: (meta[:end_of_expression] || meta)[:column] - 1 163 | } 164 | }, 165 | selection_range: %Range{ 166 | start: %Position{line: meta[:line] - 1, character: meta[:column] - 1}, 167 | end: %Position{line: meta[:line] - 1, character: meta[:column] - 1} 168 | } 169 | } 170 | end 171 | 172 | defp walker({type, meta, [name | _children]}, _) when type in [:test, :feature, :property] do 173 | %DocumentSymbol{ 174 | name: String.replace("#{type} #{Macro.to_string(unliteral(name))}", "\n", ""), 175 | children: [], 176 | kind: SymbolKind.constructor(), 177 | range: %Range{ 178 | start: %Position{ 179 | line: meta[:line] - 1, 180 | character: meta[:column] - 1 181 | }, 182 | end: %Position{ 183 | line: (meta[:end] || meta[:end_of_expression] || meta)[:line] - 1, 184 | character: (meta[:end] || meta[:end_of_expression] || meta)[:column] - 1 185 | } 186 | }, 187 | selection_range: %Range{ 188 | start: %Position{line: meta[:line] - 1, character: meta[:column] - 1}, 189 | end: %Position{line: meta[:line] - 1, character: meta[:column] - 1} 190 | } 191 | } 192 | end 193 | 194 | defp walker({type, meta, [name | _children]}, _) when type in [:def, :defp, :defmacro, :defmacro] do 195 | %DocumentSymbol{ 196 | name: String.replace("#{type} #{name |> unliteral() |> Macro.to_string()}", "\n", ""), 197 | children: [], 198 | kind: elixir_kind_to_lsp_kind(type), 199 | range: %Range{ 200 | start: %Position{ 201 | line: meta[:line] - 1, 202 | character: meta[:column] - 1 203 | }, 204 | end: %Position{ 205 | line: (meta[:end] || meta[:end_of_expression] || meta)[:line] - 1, 206 | character: (meta[:end] || meta[:end_of_expression] || meta)[:column] - 1 207 | } 208 | }, 209 | selection_range: %Range{ 210 | start: %Position{line: meta[:line] - 1, character: meta[:column] - 1}, 211 | end: %Position{line: meta[:line] - 1, character: meta[:column] - 1} 212 | } 213 | } 214 | end 215 | 216 | defp walker(_ast, _) do 217 | nil 218 | end 219 | 220 | defp unliteral(ast) do 221 | Macro.prewalk(ast, fn 222 | {:__literal__, _, [literal]} -> 223 | literal 224 | 225 | node -> 226 | node 227 | end) 228 | end 229 | 230 | defp elixir_kind_to_lsp_kind(:defstruct), do: SymbolKind.struct() 231 | defp elixir_kind_to_lsp_kind(:@), do: SymbolKind.property() 232 | 233 | defp elixir_kind_to_lsp_kind(kind) when kind in [:def, :defp, :defmacro, :defmacrop, :test, :describe], 234 | do: SymbolKind.function() 235 | end 236 | -------------------------------------------------------------------------------- /lib/next_ls/extensions/credo_extension.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.CredoExtension do 2 | @moduledoc false 3 | use GenServer 4 | 5 | alias GenLSP.Enumerations.DiagnosticSeverity 6 | alias GenLSP.Structures.CodeDescription 7 | alias GenLSP.Structures.Diagnostic 8 | alias GenLSP.Structures.Position 9 | alias GenLSP.Structures.Range 10 | alias NextLS.DiagnosticCache 11 | alias NextLS.Runtime 12 | 13 | def start_link(args) do 14 | GenServer.start_link( 15 | __MODULE__, 16 | Keyword.take(args, [:cache, :registry, :publisher, :task_supervisor, :settings, :logger]), 17 | Keyword.take(args, [:name]) 18 | ) 19 | end 20 | 21 | @impl GenServer 22 | def init(args) do 23 | cache = Keyword.fetch!(args, :cache) 24 | registry = Keyword.fetch!(args, :registry) 25 | publisher = Keyword.fetch!(args, :publisher) 26 | task_supervisor = Keyword.fetch!(args, :task_supervisor) 27 | settings = Keyword.fetch!(args, :settings) 28 | logger = Keyword.fetch!(args, :logger) 29 | 30 | if settings.enable do 31 | Registry.register(registry, :extensions, :credo) 32 | 33 | NextLS.Logger.info(logger, "[extension] Credo initializing with options #{inspect(settings)}") 34 | 35 | {:ok, 36 | %{ 37 | runtimes: Map.new(), 38 | cache: cache, 39 | registry: registry, 40 | task_supervisor: task_supervisor, 41 | publisher: publisher, 42 | settings: settings, 43 | logger: logger, 44 | refresh_refs: Map.new() 45 | }} 46 | else 47 | NextLS.Logger.info(logger, "[extension] Credo disabled") 48 | :ignore 49 | end 50 | end 51 | 52 | @impl GenServer 53 | 54 | def handle_info({:runtime_ready, _, _}, state), do: {:noreply, state} 55 | 56 | def handle_info({:compiler, _diagnostics}, state) do 57 | {state, refresh_refs} = 58 | dispatch(state.registry, :runtimes, fn entries -> 59 | # loop over runtimes 60 | for {runtime, %{path: path}} <- entries, reduce: {state, %{}} do 61 | {state, refs} -> 62 | # determine the existence of Credo and memoize the result 63 | state = 64 | if Map.has_key?(state.runtimes, runtime) do 65 | state 66 | else 67 | case Runtime.call(runtime, {Code, :ensure_loaded?, [Credo]}) do 68 | {:ok, true} -> 69 | :next_ls 70 | |> :code.priv_dir() 71 | |> Path.join("monkey/_next_ls_private_credo.ex") 72 | |> then(&Runtime.call(runtime, {Code, :compile_file, [&1]})) 73 | 74 | Runtime.call(runtime, {Application, :ensure_all_started, [:credo]}) 75 | Runtime.call(runtime, {GenServer, :call, [Credo.CLI.Output.Shell, {:suppress_output, true}]}) 76 | 77 | put_in(state.runtimes[runtime], true) 78 | 79 | _ -> 80 | state 81 | end 82 | end 83 | 84 | # if runtime has Credo 85 | if state.runtimes[runtime] do 86 | namespace = {:credo, path} 87 | DiagnosticCache.clear(state.cache, namespace) 88 | 89 | task = 90 | Task.Supervisor.async_nolink(state.task_supervisor, fn -> 91 | case Runtime.call(runtime, {:_next_ls_private_credo, :issues, [state.settings.cli_options, path]}) do 92 | {:ok, issues} -> issues 93 | _error -> [] 94 | end 95 | end) 96 | 97 | {state, Map.put(refs, task.ref, namespace)} 98 | else 99 | {state, refs} 100 | end 101 | end 102 | end) 103 | 104 | send(state.publisher, :publish) 105 | 106 | {:noreply, put_in(state.refresh_refs, refresh_refs)} 107 | end 108 | 109 | def handle_info({ref, issues}, %{refresh_refs: refs} = state) when is_map_key(refs, ref) do 110 | Process.demonitor(ref, [:flush]) 111 | {{:credo, path} = namespace, refs} = Map.pop(refs, ref) 112 | 113 | for issue <- issues do 114 | column = (issue.column || 1) - 1 115 | 116 | diagnostic = %Diagnostic{ 117 | range: %Range{ 118 | start: %Position{ 119 | line: issue.line_no - 1, 120 | character: column 121 | }, 122 | end: %Position{ 123 | line: issue.line_no - 1, 124 | character: column + String.length(issue.trigger) 125 | } 126 | }, 127 | severity: category_to_severity(issue.category), 128 | data: %{check: issue.check, file: issue.filename, namespace: :credo}, 129 | source: "credo", 130 | code: Macro.to_string(issue.check), 131 | code_description: %CodeDescription{ 132 | href: "https://hexdocs.pm/credo/#{Macro.to_string(issue.check)}.html" 133 | }, 134 | message: issue.message 135 | } 136 | 137 | DiagnosticCache.put(state.cache, namespace, Path.join(path, issue.filename), diagnostic) 138 | end 139 | 140 | send(state.publisher, :publish) 141 | 142 | {:noreply, put_in(state.refresh_refs, refs)} 143 | end 144 | 145 | def handle_info({:DOWN, ref, :process, _pid, _reason}, %{refresh_refs: refs} = state) when is_map_key(refs, ref) do 146 | {_, refs} = Map.pop(refs, ref) 147 | 148 | {:noreply, put_in(state.refresh_refs, refs)} 149 | end 150 | 151 | defp dispatch(registry, key, callback) do 152 | ref = make_ref() 153 | me = self() 154 | 155 | Registry.dispatch(registry, key, fn entries -> 156 | result = callback.(entries) 157 | 158 | send(me, {ref, result}) 159 | end) 160 | 161 | receive do 162 | {^ref, result} -> result 163 | end 164 | end 165 | 166 | defp category_to_severity(:refactor), do: DiagnosticSeverity.error() 167 | defp category_to_severity(:warning), do: DiagnosticSeverity.warning() 168 | defp category_to_severity(:design), do: DiagnosticSeverity.information() 169 | 170 | defp category_to_severity(:consistency), do: DiagnosticSeverity.information() 171 | 172 | defp category_to_severity(:readability), do: DiagnosticSeverity.information() 173 | end 174 | -------------------------------------------------------------------------------- /lib/next_ls/extensions/credo_extension/code_action.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.CredoExtension.CodeAction do 2 | @moduledoc false 3 | 4 | @behaviour NextLS.CodeActionable 5 | 6 | alias NextLS.CodeActionable.Data 7 | alias NextLS.CredoExtension.CodeAction.RemoveDebugger 8 | 9 | @debug_checks ~w( 10 | Elixir.Credo.Check.Warning.Dbg 11 | Elixir.Credo.Check.Warning.IExPry 12 | Elixir.Credo.Check.Warning.IoInspect 13 | Elixir.Credo.Check.Warning.IoPuts 14 | Elixir.Credo.Check.Warning.MixEnv 15 | ) 16 | @impl true 17 | def from(%Data{} = data) do 18 | case data.diagnostic.data do 19 | %{"check" => check} when check in @debug_checks -> 20 | RemoveDebugger.new(data.diagnostic, data.document, data.uri) 21 | 22 | _ -> 23 | [] 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/next_ls/extensions/credo_extension/code_action/remove_debugger.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.CredoExtension.CodeAction.RemoveDebugger do 2 | @moduledoc false 3 | 4 | alias GenLSP.Structures.CodeAction 5 | alias GenLSP.Structures.Diagnostic 6 | alias GenLSP.Structures.Position 7 | alias GenLSP.Structures.Range 8 | alias GenLSP.Structures.TextEdit 9 | alias GenLSP.Structures.WorkspaceEdit 10 | alias NextLS.EditHelpers 11 | alias Sourceror.Zipper, as: Z 12 | 13 | @line_length 121 14 | 15 | def new(%Diagnostic{} = diagnostic, text, uri) do 16 | range = diagnostic.range 17 | 18 | with {:ok, ast, comments} <- parse(text), 19 | {:ok, debugger_node} <- find_debugger(ast, range) do 20 | indent = EditHelpers.get_indent(text, range.start.line) 21 | ast_without_debugger = remove_debugger(debugger_node) 22 | range = make_range(debugger_node) 23 | 24 | comments = 25 | Enum.filter(comments, fn comment -> 26 | comment.line > range.start.line && comment.line <= range.end.line 27 | end) 28 | 29 | to_algebra_opts = [comments: comments] 30 | doc = Code.quoted_to_algebra(ast_without_debugger, to_algebra_opts) 31 | formatted = doc |> Inspect.Algebra.format(@line_length) |> IO.iodata_to_binary() 32 | 33 | [ 34 | %CodeAction{ 35 | title: make_title(debugger_node), 36 | diagnostics: [diagnostic], 37 | edit: %WorkspaceEdit{ 38 | changes: %{ 39 | uri => [ 40 | %TextEdit{ 41 | new_text: EditHelpers.add_indent_to_edit(formatted, indent), 42 | range: range 43 | } 44 | ] 45 | } 46 | } 47 | } 48 | ] 49 | else 50 | _ -> 51 | [] 52 | end 53 | end 54 | 55 | defp find_debugger(ast, range) do 56 | pos = %{ 57 | start: [line: range.start.line + 1, column: range.start.character + 1], 58 | end: [line: range.end.line + 1, column: range.end.character + 1] 59 | } 60 | 61 | {_, results} = 62 | ast 63 | |> Z.zip() 64 | |> Z.traverse([], fn tree, acc -> 65 | node = Z.node(tree) 66 | range = Sourceror.get_range(node) 67 | 68 | # range.start <= diagnostic_pos.start <= diagnostic_pos.end <= range.end 69 | if (matches_debug?(node) || matches_pipe?(node)) && range && 70 | Sourceror.compare_positions(range.start, pos.start) in [:lt, :eq] && 71 | Sourceror.compare_positions(range.end, pos.end) in [:gt, :eq] do 72 | {tree, [node | acc]} 73 | else 74 | {tree, acc} 75 | end 76 | end) 77 | 78 | result = 79 | Enum.min_by( 80 | results, 81 | fn node -> 82 | range = Sourceror.get_range(node) 83 | 84 | pos.start[:column] - range.start[:column] + range.end[:column] - pos.end[:column] 85 | end, 86 | fn -> nil end 87 | ) 88 | 89 | result = 90 | Enum.find(results, result, fn 91 | {:|>, _, [_first, ^result]} -> true 92 | _ -> false 93 | end) 94 | 95 | case result do 96 | nil -> {:error, "could find a debugger to remove"} 97 | node -> {:ok, node} 98 | end 99 | end 100 | 101 | defp parse(lines) do 102 | lines 103 | |> Enum.join("\n") 104 | |> Spitfire.parse_with_comments(literal_encoder: &{:ok, {:__block__, &2, [&1]}}) 105 | |> case do 106 | {:error, ast, comments, _errors} -> 107 | {:ok, ast, comments} 108 | 109 | other -> 110 | other 111 | end 112 | end 113 | 114 | defp make_range(original_ast) do 115 | range = Sourceror.get_range(original_ast) 116 | 117 | %Range{ 118 | start: %Position{line: range.start[:line] - 1, character: range.start[:column] - 1}, 119 | end: %Position{line: range.end[:line] - 1, character: range.end[:column] - 1} 120 | } 121 | end 122 | 123 | defp matches_pipe?({:|>, _, [_, arg]}), do: matches_debug?(arg) 124 | defp matches_pipe?(_), do: false 125 | 126 | defp matches_debug?({:dbg, _, _}), do: true 127 | 128 | defp matches_debug?({{:., _, [{:__aliases__, _, [:IO]}, f]}, _, _}) when f in [:puts, :inspect], do: true 129 | 130 | defp matches_debug?({{:., _, [{:__aliases__, _, [:IEx]}, :pry]}, _, _}), do: true 131 | defp matches_debug?({{:., _, [{:__aliases__, _, [:Mix]}, :env]}, _, _}), do: true 132 | defp matches_debug?({{:., _, [{:__aliases__, _, [:Kernel]}, :dbg]}, _, _}), do: true 133 | defp matches_debug?(_), do: false 134 | 135 | defp remove_debugger({:|>, _, [arg, _function]}), do: arg 136 | defp remove_debugger({{:., _, [{:__aliases__, _, [:IO]}, :inspect]}, _, [arg | _]}), do: arg 137 | defp remove_debugger({{:., _, [{:__aliases__, _, [:Kernel]}, :dbg]}, _, [arg | _]}), do: arg 138 | defp remove_debugger({:dbg, _, [arg | _]}), do: arg 139 | defp remove_debugger(_node), do: {:__block__, [], []} 140 | 141 | defp make_title({_, ctx, _} = node), do: "Remove `#{format_node(node)}` #{ctx[:line]}:#{ctx[:column]}" 142 | defp format_node({:|>, _, [_arg, function]}), do: format_node(function) 143 | 144 | defp format_node({{:., _, [{:__aliases__, _, [module]}, function]}, _, args}), 145 | do: "&#{module}.#{function}/#{Enum.count(args)}" 146 | 147 | defp format_node({:dbg, _, args}), do: "&dbg/#{Enum.count(args)}" 148 | defp format_node(node), do: Macro.to_string(node) 149 | end 150 | -------------------------------------------------------------------------------- /lib/next_ls/extensions/elixir_extension.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.ElixirExtension do 2 | @moduledoc false 3 | use GenServer 4 | 5 | alias GenLSP.Enumerations.DiagnosticSeverity 6 | alias GenLSP.Structures.Position 7 | alias NextLS.DiagnosticCache 8 | 9 | def start_link(args) do 10 | GenServer.start_link( 11 | __MODULE__, 12 | Keyword.take(args, [:cache, :registry, :publisher]), 13 | Keyword.take(args, [:name]) 14 | ) 15 | end 16 | 17 | @impl GenServer 18 | def init(args) do 19 | cache = Keyword.fetch!(args, :cache) 20 | registry = Keyword.fetch!(args, :registry) 21 | publisher = Keyword.fetch!(args, :publisher) 22 | 23 | Registry.register(registry, :extensions, :elixir) 24 | 25 | {:ok, %{cache: cache, registry: registry, publisher: publisher}} 26 | end 27 | 28 | @impl GenServer 29 | def handle_info({:runtime_ready, _path, _pid}, state) do 30 | {:noreply, state} 31 | end 32 | 33 | def handle_info({:compiler, diagnostics}, state) when is_list(diagnostics) do 34 | DiagnosticCache.clear(state.cache, :elixir) 35 | 36 | for d <- diagnostics do 37 | DiagnosticCache.put(state.cache, :elixir, d.file, %GenLSP.Structures.Diagnostic{ 38 | severity: severity(d.severity), 39 | message: IO.iodata_to_binary(d.message), 40 | source: d.compiler_name, 41 | range: range(d.position, Map.get(d, :span)), 42 | data: metadata(d) 43 | }) 44 | end 45 | 46 | send(state.publisher, :publish) 47 | 48 | {:noreply, state} 49 | end 50 | 51 | defp severity(:error), do: DiagnosticSeverity.error() 52 | defp severity(:warning), do: DiagnosticSeverity.warning() 53 | defp severity(:info), do: DiagnosticSeverity.information() 54 | defp severity(:hint), do: DiagnosticSeverity.hint() 55 | 56 | defp range({start_line, start_col, end_line, end_col}, _) do 57 | %GenLSP.Structures.Range{ 58 | start: %Position{ 59 | line: clamp(start_line - 1), 60 | character: start_col - 1 61 | }, 62 | end: %Position{ 63 | line: clamp(end_line - 1), 64 | character: end_col - 1 65 | } 66 | } 67 | end 68 | 69 | defp range({startl, startc}, {endl, endc}) do 70 | %GenLSP.Structures.Range{ 71 | start: %Position{ 72 | line: clamp(startl - 1), 73 | character: startc - 1 74 | }, 75 | end: %Position{ 76 | line: clamp(endl - 1), 77 | character: endc - 1 78 | } 79 | } 80 | end 81 | 82 | defp range({line, col}, nil) do 83 | %GenLSP.Structures.Range{ 84 | start: %Position{ 85 | line: clamp(line - 1), 86 | character: col - 1 87 | }, 88 | end: %Position{ 89 | line: clamp(line - 1), 90 | character: 999 91 | } 92 | } 93 | end 94 | 95 | defp range(line, _) do 96 | %GenLSP.Structures.Range{ 97 | start: %Position{ 98 | line: clamp(line - 1), 99 | character: 0 100 | }, 101 | end: %Position{ 102 | line: clamp(line - 1), 103 | character: 999 104 | } 105 | } 106 | end 107 | 108 | def clamp(line), do: max(line, 0) 109 | 110 | @unused_variable ~r/variable\s\"[^\"]+\"\sis\sunused/ 111 | @require_module ~r/you\smust\srequire/ 112 | @undefined_local_function ~r/undefined function (?.*)\/(?\d) \(expected (?.*) to define such a function or for it to be imported, but none are available\)/ 113 | defp metadata(diagnostic) do 114 | base = %{"namespace" => "elixir"} 115 | 116 | cond do 117 | is_binary(diagnostic.message) and diagnostic.message =~ @unused_variable -> 118 | Map.put(base, "type", "unused_variable") 119 | 120 | is_binary(diagnostic.message) and diagnostic.message =~ @require_module -> 121 | Map.put(base, "type", "require") 122 | 123 | is_binary(diagnostic.message) and diagnostic.message =~ @undefined_local_function -> 124 | info = Regex.named_captures(@undefined_local_function, diagnostic.message) 125 | base |> Map.put("type", "undefined-function") |> Map.put("info", info) 126 | 127 | true -> 128 | base 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /lib/next_ls/extensions/elixir_extension/code_action.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.ElixirExtension.CodeAction do 2 | @moduledoc false 3 | 4 | @behaviour NextLS.CodeActionable 5 | 6 | alias NextLS.CodeActionable.Data 7 | alias NextLS.ElixirExtension.CodeAction.Require 8 | alias NextLS.ElixirExtension.CodeAction.UndefinedFunction 9 | alias NextLS.ElixirExtension.CodeAction.UnusedVariable 10 | 11 | @impl true 12 | def from(%Data{} = data) do 13 | case data.diagnostic.data do 14 | %{"type" => "unused_variable"} -> 15 | UnusedVariable.new(data.diagnostic, data.document, data.uri) 16 | 17 | %{"type" => "require"} -> 18 | Require.new(data.diagnostic, data.document, data.uri) 19 | 20 | %{"type" => "undefined-function"} -> 21 | UndefinedFunction.new(data.diagnostic, data.document, data.uri) 22 | 23 | _ -> 24 | [] 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/next_ls/extensions/elixir_extension/code_action/require.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.ElixirExtension.CodeAction.Require do 2 | @moduledoc false 3 | 4 | alias GenLSP.Structures.CodeAction 5 | alias GenLSP.Structures.Diagnostic 6 | alias GenLSP.Structures.Position 7 | alias GenLSP.Structures.Range 8 | alias GenLSP.Structures.TextEdit 9 | alias GenLSP.Structures.WorkspaceEdit 10 | alias NextLS.ASTHelpers 11 | 12 | @one_indentation_level " " 13 | @spec new(diagnostic :: Diagnostic.t(), [text :: String.t()], uri :: String.t()) :: [CodeAction.t()] 14 | def new(%Diagnostic{} = diagnostic, text, uri) do 15 | range = diagnostic.range 16 | 17 | with {:ok, require_module} <- get_edit(diagnostic.message), 18 | {:ok, ast} <- parse_ast(text), 19 | {:ok, defm} <- ASTHelpers.get_surrounding_module(ast, range.start) do 20 | indentation = get_indent(text, defm) 21 | nearest = find_nearest_node_for_require(defm) 22 | range = get_edit_range(nearest) 23 | 24 | [ 25 | %CodeAction{ 26 | title: "Add missing require for #{require_module}", 27 | diagnostics: [diagnostic], 28 | edit: %WorkspaceEdit{ 29 | changes: %{ 30 | uri => [ 31 | %TextEdit{ 32 | new_text: indentation <> "require #{require_module}\n", 33 | range: range 34 | } 35 | ] 36 | } 37 | } 38 | } 39 | ] 40 | else 41 | _error -> 42 | [] 43 | end 44 | end 45 | 46 | defp parse_ast(text) do 47 | text 48 | |> Enum.join("\n") 49 | |> Spitfire.parse() 50 | end 51 | 52 | @module_name ~r/require\s+([^\s]+)\s+before/ 53 | defp get_edit(message) do 54 | case Regex.run(@module_name, message) do 55 | [_, module] -> {:ok, module} 56 | _ -> {:error, "unable to find require"} 57 | end 58 | end 59 | 60 | # Context starts from 1 while LSP starts from 0 61 | # which works for us since we want to insert the require on the next line 62 | defp get_edit_range(context) do 63 | %Range{ 64 | start: %Position{line: context[:line], character: 0}, 65 | end: %Position{line: context[:line], character: 0} 66 | } 67 | end 68 | 69 | @indent ~r/^(\s*).*/ 70 | defp get_indent(text, {_, defm_context, _}) do 71 | line = defm_context[:line] - 1 72 | 73 | indent = 74 | text 75 | |> Enum.at(line) 76 | |> then(&Regex.run(@indent, &1)) 77 | |> List.last() 78 | 79 | indent <> @one_indentation_level 80 | end 81 | 82 | @top_level_macros [:import, :alias, :require] 83 | defp find_nearest_node_for_require({:defmodule, context, _} = ast) do 84 | top_level_macros = 85 | ast 86 | |> Macro.prewalker() 87 | |> Enum.filter(fn 88 | {:@, _, [{:moduledoc, _, _}]} -> true 89 | {macro, _, _} when macro in @top_level_macros -> true 90 | _ -> false 91 | end) 92 | 93 | case top_level_macros do 94 | [] -> 95 | context 96 | 97 | _ -> 98 | {_, context, _} = Enum.max_by(top_level_macros, fn {_, ctx, _} -> ctx[:line] end) 99 | context 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/next_ls/extensions/elixir_extension/code_action/undefined_function.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.ElixirExtension.CodeAction.UndefinedFunction do 2 | @moduledoc false 3 | 4 | alias GenLSP.Structures.CodeAction 5 | alias GenLSP.Structures.Diagnostic 6 | alias GenLSP.Structures.Range 7 | alias GenLSP.Structures.TextEdit 8 | alias GenLSP.Structures.WorkspaceEdit 9 | alias NextLS.ASTHelpers 10 | 11 | def new(diagnostic, text, uri) do 12 | %Diagnostic{range: range, data: %{"info" => %{"name" => name, "arity" => arity}}} = diagnostic 13 | 14 | with {:ok, ast} <- 15 | text 16 | |> Enum.join("\n") 17 | |> Spitfire.parse(literal_encoder: &{:ok, {:__block__, &2, [&1]}}), 18 | {:ok, {:defmodule, meta, _} = defm} <- ASTHelpers.get_surrounding_module(ast, range.start) do 19 | indentation = get_indent(text, defm) 20 | 21 | position = %GenLSP.Structures.Position{ 22 | line: meta[:end][:line] - 1, 23 | character: 0 24 | } 25 | 26 | params = if arity == "0", do: "", else: Enum.map_join(1..String.to_integer(arity), ", ", fn i -> "param#{i}" end) 27 | 28 | action = fn title, new_text -> 29 | %CodeAction{ 30 | title: title, 31 | diagnostics: [diagnostic], 32 | edit: %WorkspaceEdit{ 33 | changes: %{ 34 | uri => [ 35 | %TextEdit{ 36 | new_text: new_text, 37 | range: %Range{ 38 | start: position, 39 | end: position 40 | } 41 | } 42 | ] 43 | } 44 | } 45 | } 46 | end 47 | 48 | [ 49 | action.("Create public function #{name}/#{arity}", """ 50 | 51 | #{indentation}def #{name}(#{params}) do 52 | 53 | #{indentation}end 54 | """), 55 | action.("Create private function #{name}/#{arity}", """ 56 | 57 | #{indentation}defp #{name}(#{params}) do 58 | 59 | #{indentation}end 60 | """) 61 | ] 62 | end 63 | end 64 | 65 | @one_indentation_level " " 66 | @indent ~r/^(\s*).*/ 67 | defp get_indent(text, {_, defm_context, _}) do 68 | line = defm_context[:line] - 1 69 | 70 | indent = 71 | text 72 | |> Enum.at(line) 73 | |> then(&Regex.run(@indent, &1)) 74 | |> List.last() 75 | 76 | indent <> @one_indentation_level 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/next_ls/extensions/elixir_extension/code_action/unused_variable.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.ElixirExtension.CodeAction.UnusedVariable do 2 | @moduledoc false 3 | 4 | alias GenLSP.Structures.CodeAction 5 | alias GenLSP.Structures.Diagnostic 6 | alias GenLSP.Structures.Range 7 | alias GenLSP.Structures.TextEdit 8 | alias GenLSP.Structures.WorkspaceEdit 9 | 10 | def new(diagnostic, _text, uri) do 11 | %Diagnostic{range: %{start: start}} = diagnostic 12 | 13 | [ 14 | %CodeAction{ 15 | title: "Underscore unused variable", 16 | diagnostics: [diagnostic], 17 | edit: %WorkspaceEdit{ 18 | changes: %{ 19 | uri => [ 20 | %TextEdit{ 21 | new_text: "_", 22 | range: %Range{ 23 | start: start, 24 | end: start 25 | } 26 | } 27 | ] 28 | } 29 | } 30 | } 31 | ] 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/next_ls/helpers/ast_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.ASTHelpers do 2 | @moduledoc false 3 | alias GenLSP.Structures.Position 4 | alias Sourceror.Zipper 5 | 6 | defmodule Attributes do 7 | @moduledoc false 8 | @spec get_attribute_reference_name(String.t(), integer(), integer()) :: String.t() | nil 9 | def get_attribute_reference_name(file, line, column) do 10 | ast = ast_from_file(file) 11 | 12 | {_ast, name} = 13 | Macro.prewalk(ast, nil, fn 14 | {:@, [line: ^line, column: ^column], [{name, _meta, nil}]} = ast, _acc -> {ast, "@#{name}"} 15 | other, acc -> {other, acc} 16 | end) 17 | 18 | name 19 | end 20 | 21 | @spec get_module_attributes(String.t(), module()) :: [{atom(), String.t(), integer(), integer()}] 22 | def get_module_attributes(file, module) do 23 | reserved_attributes = Module.reserved_attributes() 24 | 25 | symbols = parse_symbols(file, module) 26 | 27 | Enum.filter(symbols, fn 28 | {:attribute, "@" <> name, _, _} -> 29 | not Map.has_key?(reserved_attributes, String.to_atom(name)) 30 | 31 | _other -> 32 | false 33 | end) 34 | end 35 | 36 | defp parse_symbols(file, module) do 37 | ast = ast_from_file(file) 38 | 39 | {_ast, %{symbols: symbols}} = 40 | Macro.traverse(ast, %{modules: [], symbols: []}, &prewalk/2, &postwalk(&1, &2, module)) 41 | 42 | symbols 43 | end 44 | 45 | # add module name to modules stack on enter 46 | defp prewalk({:defmodule, _, [{:__aliases__, _, module_name_atoms} | _]} = ast, acc) do 47 | modules = [module_name_atoms | acc.modules] 48 | {ast, %{acc | modules: modules}} 49 | end 50 | 51 | defp prewalk(ast, acc), do: {ast, acc} 52 | 53 | defp postwalk({:@, meta, [{name, _, args}]} = ast, acc, module) when is_list(args) do 54 | ast_module = 55 | acc.modules 56 | |> Enum.reverse() 57 | |> List.flatten() 58 | |> Module.concat() 59 | 60 | if module == ast_module do 61 | symbols = [{:attribute, "@#{name}", meta[:line], meta[:column]} | acc.symbols] 62 | {ast, %{acc | symbols: symbols}} 63 | else 64 | {ast, acc} 65 | end 66 | end 67 | 68 | # remove module name from modules stack on exit 69 | defp postwalk({:defmodule, _, [{:__aliases__, _, _modules} | _]} = ast, acc, _module) do 70 | [_exit_mudule | modules] = acc.modules 71 | {ast, %{acc | modules: modules}} 72 | end 73 | 74 | defp postwalk(ast, acc, _module), do: {ast, acc} 75 | 76 | defp ast_from_file(file) do 77 | file |> File.read!() |> NextLS.Parser.parse!(columns: true) 78 | end 79 | end 80 | 81 | defmodule Aliases do 82 | @moduledoc """ 83 | Responsible for extracting the relevant portion from a single or multi alias. 84 | 85 | ## Example 86 | 87 | ```elixir 88 | alias Foo.Bar.Baz 89 | # ^^^^^^^^^^^ 90 | 91 | alias Foo.Bar.{Baz, Bing} 92 | # ^^^ ^^^^ 93 | 94 | alias Foo.Bar.{ 95 | Baz, 96 | # ^^^ 97 | Bing 98 | # ^^^^ 99 | } 100 | ``` 101 | """ 102 | 103 | def extract_alias_range(code, {start, stop}, ale) do 104 | {_, range} = 105 | code 106 | |> NextLS.Parser.parse!(columns: true, token_metadata: true) 107 | |> Macro.prewalk(nil, fn 108 | ast, nil = range -> 109 | range = 110 | case ast do 111 | {:__aliases__, meta, aliases} -> 112 | if ale == List.last(aliases) do 113 | found_range = 114 | {{meta[:line], meta[:column]}, 115 | {meta[:last][:line], meta[:last][:column] + String.length(to_string(ale)) - 1}} 116 | 117 | if NextLS.ASTHelpers.inside?({{start.line, start.col}, {stop.line, stop.col}}, found_range) do 118 | found_range 119 | else 120 | range 121 | end 122 | else 123 | range 124 | end 125 | 126 | _ -> 127 | range 128 | end 129 | 130 | {ast, range} 131 | 132 | ast, range -> 133 | {ast, range} 134 | end) 135 | 136 | range 137 | end 138 | end 139 | 140 | def inside?(outer, {{_, _}, {_, _}} = target) do 141 | {{outer_startl, outer_startc}, {outer_endl, outer_endc}} = outer 142 | {target_start, target_end} = target 143 | 144 | Enum.all?([target_start, target_end], fn {line, col} -> 145 | if outer_startl <= line and line <= outer_endl do 146 | cond do 147 | outer_startl < line and line < outer_endl -> true 148 | outer_startl == line and outer_startc <= col -> true 149 | outer_endl == line and col <= outer_endc -> true 150 | true -> false 151 | end 152 | else 153 | false 154 | end 155 | end) 156 | end 157 | 158 | defp sourceror_inside?(range, position) do 159 | Sourceror.compare_positions(range.start, position) in [:lt, :eq] && 160 | Sourceror.compare_positions(range.end, position) in [:gt, :eq] 161 | end 162 | 163 | @spec get_surrounding_module(ast :: Macro.t(), position :: Position.t()) :: {:ok, Macro.t()} | {:error, String.t()} 164 | def get_surrounding_module(ast, position) do 165 | # TODO: this should take elixir positions and not LSP positions 166 | position = [line: position.line + 1, column: position.character + 1] 167 | 168 | {_zipper, acc} = 169 | ast 170 | |> Zipper.zip() 171 | |> Zipper.traverse_while(nil, fn tree, acc -> 172 | node = Zipper.node(tree) 173 | node_range = Sourceror.Range.get_range(node) 174 | 175 | is_inside = 176 | case node_range do 177 | nil -> false 178 | _ -> sourceror_inside?(node_range, position) 179 | end 180 | 181 | acc = 182 | with true <- is_inside, 183 | {:defmodule, _, _} <- node do 184 | node 185 | else 186 | _ -> acc 187 | end 188 | 189 | cond do 190 | is_inside and match?({_, _, [_ | _]}, node) -> 191 | {:cont, tree, acc} 192 | 193 | is_inside and match?({_, _, []}, node) -> 194 | {:halt, tree, acc} 195 | 196 | true -> 197 | {:cont, tree, acc} 198 | end 199 | end) 200 | 201 | with {:ok, nil} <- {:ok, acc} do 202 | {:error, :not_found} 203 | end 204 | end 205 | 206 | def top(nil, acc, _callback), do: acc 207 | 208 | def top(%Zipper{path: nil} = zipper, acc, callback), do: callback.(Zipper.node(zipper), zipper, acc) 209 | 210 | def top(zipper, acc, callback) do 211 | node = Zipper.node(zipper) 212 | acc = callback.(node, zipper, acc) 213 | 214 | zipper = Zipper.up(zipper) 215 | 216 | top(zipper, acc, callback) 217 | end 218 | end 219 | -------------------------------------------------------------------------------- /lib/next_ls/helpers/edit_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.EditHelpers do 2 | @moduledoc false 3 | 4 | @doc """ 5 | This adds indentation to all lines except the first since the LSP expects a range for edits, 6 | where we get the range with the already original indentation for starters. 7 | 8 | It also skips empty lines since they don't need indentation. 9 | """ 10 | @spec add_indent_to_edit(text :: String.t(), indent :: String.t()) :: String.t() 11 | @blank_lines ["", "\n"] 12 | def add_indent_to_edit(text, indent) do 13 | [first | rest] = String.split(text, "\n") 14 | 15 | if rest == [] do 16 | first 17 | else 18 | indented = 19 | Enum.map_join(rest, "\n", fn line -> 20 | if line in @blank_lines do 21 | line 22 | else 23 | indent <> line 24 | end 25 | end) 26 | 27 | first <> "\n" <> indented 28 | end 29 | end 30 | 31 | @doc """ 32 | Gets the indentation level at the line number desired 33 | """ 34 | @spec get_indent(text :: [String.t()], line :: non_neg_integer()) :: String.t() 35 | def get_indent(text, line) do 36 | text 37 | |> Enum.at(line) 38 | |> then(&Regex.run(~r/^(\s*).*/, &1)) 39 | |> List.last() 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/next_ls/logger.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.Logger do 2 | @moduledoc false 3 | use GenServer 4 | 5 | require Logger 6 | 7 | def start_link(arg) do 8 | GenServer.start_link(__MODULE__, arg, Keyword.take(arg, [:name])) 9 | end 10 | 11 | def log(server, msg), do: GenServer.cast(server, {:log, :log, msg}) 12 | def error(server, msg), do: GenServer.cast(server, {:log, :error, msg}) 13 | def info(server, msg), do: GenServer.cast(server, {:log, :info, msg}) 14 | def warning(server, msg), do: GenServer.cast(server, {:log, :warning, msg}) 15 | 16 | def show_message(server, type, msg) when type in [:error, :warning, :info, :log] do 17 | GenServer.cast(server, {:show_message, type, msg}) 18 | end 19 | 20 | def init(args) do 21 | lsp = Keyword.fetch!(args, :lsp) 22 | {:ok, %{lsp: lsp}} 23 | end 24 | 25 | def handle_cast({:log, type, msg}, state) do 26 | apply(GenLSP, type, [state.lsp, String.trim("[Next LS] #{msg}")]) 27 | 28 | case type do 29 | :log -> Logger.debug(msg) 30 | :warning -> Logger.warning(msg) 31 | :error -> Logger.error(msg) 32 | :info -> Logger.info(msg) 33 | end 34 | 35 | {:noreply, state} 36 | end 37 | 38 | def handle_cast({:show_message, type, msg}, state) do 39 | GenLSP.notify(state.lsp, %GenLSP.Notifications.WindowShowMessage{ 40 | params: %GenLSP.Structures.ShowMessageParams{ 41 | type: apply(GenLSP.Enumerations.MessageType, type, []), 42 | message: msg 43 | } 44 | }) 45 | 46 | {:noreply, state} 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/next_ls/lsp_supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.LSPSupervisor do 2 | @moduledoc false 3 | 4 | use Supervisor 5 | 6 | @env Mix.env() 7 | 8 | def start_link(init_arg) do 9 | Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__) 10 | end 11 | 12 | @impl true 13 | def init(_init_arg) do 14 | if @env == :test do 15 | :ignore 16 | else 17 | {m, f, a} = Application.get_env(:next_ls, :arg_parser) 18 | 19 | argv = apply(m, f, a) 20 | 21 | {opts, _, _invalid} = 22 | OptionParser.parse(argv, strict: [version: :boolean, help: :boolean, stdio: :boolean, port: :integer]) 23 | 24 | help_text = """ 25 | Next LS v#{NextLS.version()} 26 | 27 | The language server for Elixir that #{IO.ANSI.italic()}#{IO.ANSI.bright()}just#{IO.ANSI.reset()} works. 28 | 29 | Author: Mitchell Hanberg 30 | Home page: https://www.elixir-tools.dev/next-ls 31 | Source code: https://github.com/elixir-tools/next-ls 32 | 33 | nextls [flags] 34 | 35 | #{IO.ANSI.bright()}FLAGS#{IO.ANSI.reset()} 36 | 37 | --stdio Use stdio as the transport mechanism 38 | --port Use TCP as the transport mechanism, with the given port 39 | --help Show help 40 | --version Show nextls version 41 | """ 42 | 43 | cond do 44 | opts[:help] -> 45 | IO.puts(help_text) 46 | 47 | System.halt(0) 48 | 49 | opts[:version] -> 50 | IO.puts("#{NextLS.version()}") 51 | System.halt(0) 52 | 53 | true -> 54 | :noop 55 | end 56 | 57 | buffer_opts = 58 | cond do 59 | opts[:stdio] -> 60 | [] 61 | 62 | is_integer(opts[:port]) -> 63 | IO.puts("Starting on port #{opts[:port]}") 64 | [communication: {GenLSP.Communication.TCP, [port: opts[:port]]}] 65 | 66 | true -> 67 | IO.puts(help_text) 68 | 69 | System.halt(1) 70 | end 71 | 72 | auto_update = 73 | if "NEXTLS_AUTO_UPDATE" |> System.get_env("false") |> String.to_existing_atom() do 74 | [ 75 | binpath: 76 | System.get_env( 77 | "NEXTLS_BINPATH", 78 | Path.expand("~/.cache/elixir-tools/nextls/bin/nextls") 79 | ), 80 | api_host: System.get_env("NEXTLS_GITHUB_API", "https://api.github.com"), 81 | github_host: System.get_env("NEXTLS_GITHUB", "https://github.com"), 82 | current_version: Version.parse!(NextLS.version()) 83 | ] 84 | else 85 | false 86 | end 87 | 88 | children = [ 89 | {DynamicSupervisor, name: NextLS.DynamicSupervisor}, 90 | {Task.Supervisor, name: NextLS.TaskSupervisor}, 91 | {Task.Supervisor, name: :runtime_task_supervisor}, 92 | {GenLSP.Buffer, [name: NextLS.Buffer] ++ buffer_opts}, 93 | {NextLS.DiagnosticCache, name: :diagnostic_cache}, 94 | {Registry, name: NextLS.Registry, keys: :duplicate}, 95 | {NextLS, 96 | auto_update: auto_update, 97 | buffer: NextLS.Buffer, 98 | cache: :diagnostic_cache, 99 | task_supervisor: NextLS.TaskSupervisor, 100 | runtime_task_supervisor: :runtime_task_supervisor, 101 | dynamic_supervisor: NextLS.DynamicSupervisor, 102 | registry: NextLS.Registry} 103 | ] 104 | 105 | Supervisor.init(children, strategy: :one_for_one) 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/next_ls/opentelemetry/gen_lsp.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.OpentelemetryGenLSP do 2 | @moduledoc false 3 | require Logger 4 | 5 | @tracer_id __MODULE__ 6 | 7 | def setup do 8 | :ok = 9 | :telemetry.attach_many( 10 | "gen_lsp-handler", 11 | [ 12 | [:gen_lsp, :notify, :server, :start], 13 | [:gen_lsp, :notify, :server, :stop], 14 | [:gen_lsp, :request, :server, :start], 15 | [:gen_lsp, :request, :server, :stop], 16 | [:gen_lsp, :request, :client, :start], 17 | [:gen_lsp, :request, :client, :stop], 18 | [:gen_lsp, :notification, :client, :start], 19 | [:gen_lsp, :notification, :client, :stop], 20 | [:gen_lsp, :handle_request, :start], 21 | [:gen_lsp, :handle_request, :stop], 22 | [:gen_lsp, :handle_notification, :start], 23 | [:gen_lsp, :handle_notification, :stop], 24 | [:gen_lsp, :handle_info, :start], 25 | [:gen_lsp, :handle_info, :stop] 26 | # [:gen_lsp, :buffer, :outgoing, :start], 27 | # [:gen_lsp, :buffer, :outgoing, :stop], 28 | # [:gen_lsp, :buffer, :incoming, :start], 29 | # [:gen_lsp, :buffer, :incoming, :stop] 30 | ], 31 | &__MODULE__.process/4, 32 | nil 33 | ) 34 | end 35 | 36 | def process([:gen_lsp, type1, type2, :start], _measurements, metadata, _config) do 37 | OpentelemetryTelemetry.start_telemetry_span( 38 | @tracer_id, 39 | :"gen_lsp.#{type1}.#{type2}", 40 | metadata, 41 | %{kind: :server, attributes: metadata} 42 | ) 43 | end 44 | 45 | def process([:gen_lsp, handle, :start], _measurements, metadata, _config) do 46 | if handle in [:handle_request, :handle_notification] do 47 | # set attribute for parent span 48 | OpenTelemetry.Tracer.set_attribute(:method, metadata[:method]) 49 | end 50 | 51 | OpentelemetryTelemetry.start_telemetry_span( 52 | @tracer_id, 53 | :"next_ls.#{handle}", 54 | metadata, 55 | %{kind: :server, attributes: metadata} 56 | ) 57 | end 58 | 59 | def process([:gen_lsp, _, _, :stop], _measurements, metadata, _config) do 60 | OpentelemetryTelemetry.set_current_telemetry_span(@tracer_id, metadata) 61 | OpentelemetryTelemetry.end_telemetry_span(@tracer_id, metadata) 62 | end 63 | 64 | def process([:gen_lsp, _, :stop], _measurements, metadata, _config) do 65 | OpentelemetryTelemetry.set_current_telemetry_span(@tracer_id, metadata) 66 | OpentelemetryTelemetry.end_telemetry_span(@tracer_id, metadata) 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/next_ls/opentelemetry/schematic.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.OpentelemetrySchematic do 2 | @moduledoc false 3 | require Logger 4 | 5 | @tracer_id __MODULE__ 6 | 7 | def setup do 8 | :ok = 9 | :telemetry.attach_many( 10 | "schematic-handler", 11 | [ 12 | [:schematic, :unify, :start], 13 | [:schematic, :unify, :stop] 14 | ], 15 | &__MODULE__.process/4, 16 | nil 17 | ) 18 | end 19 | 20 | def process([:schematic, :unify, :start], _measurements, metadata, _config) do 21 | OpentelemetryTelemetry.start_telemetry_span( 22 | @tracer_id, 23 | :"schematic.unify.#{metadata.kind} #{metadata.dir}", 24 | metadata, 25 | %{kind: :server, attributes: metadata} 26 | ) 27 | end 28 | 29 | def process([:schematic, :unify, :stop], _measurements, metadata, _config) do 30 | OpentelemetryTelemetry.set_current_telemetry_span(@tracer_id, metadata) 31 | OpentelemetryTelemetry.end_telemetry_span(@tracer_id, metadata) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/next_ls/parser.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.Parser do 2 | @moduledoc false 3 | def parse!(code, opts \\ []) do 4 | {m, f} = 5 | if System.get_env("NEXTLS_SPITFIRE_ENABLED", "0") == "1" do 6 | {Spitfire, :parse!} 7 | else 8 | {Code, :string_to_quoted!} 9 | end 10 | 11 | apply(m, f, [code, opts]) 12 | end 13 | 14 | def parse(code, opts \\ []) do 15 | {m, f} = 16 | if System.get_env("NEXTLS_SPITFIRE_ENABLED", "0") == "1" do 17 | {Spitfire, :parse} 18 | else 19 | {Code, :string_to_quoted} 20 | end 21 | 22 | apply(m, f, [code, opts]) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/next_ls/progress.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.Progress do 2 | @moduledoc false 3 | 4 | alias GenLSP.Notifications.DollarProgress 5 | alias GenLSP.Structures.ProgressParams 6 | 7 | def start(lsp, token, msg) do 8 | Task.start(fn -> 9 | if lsp.assigns.client_capabilities.window.work_done_progress do 10 | GenLSP.request(lsp, %GenLSP.Requests.WindowWorkDoneProgressCreate{ 11 | id: System.unique_integer([:positive]), 12 | params: %GenLSP.Structures.WorkDoneProgressCreateParams{ 13 | token: token 14 | } 15 | }) 16 | end 17 | 18 | GenLSP.notify(lsp, %DollarProgress{ 19 | params: %ProgressParams{ 20 | token: token, 21 | value: %GenLSP.Structures.WorkDoneProgressBegin{kind: "begin", title: msg} 22 | } 23 | }) 24 | end) 25 | end 26 | 27 | def stop(lsp, token, msg \\ nil) do 28 | GenLSP.notify(lsp, %DollarProgress{ 29 | params: %ProgressParams{ 30 | token: token, 31 | value: %GenLSP.Structures.WorkDoneProgressEnd{ 32 | kind: "end", 33 | message: msg 34 | } 35 | } 36 | }) 37 | end 38 | 39 | def token do 40 | 8 41 | |> :crypto.strong_rand_bytes() 42 | |> Base.url_encode64(padding: false) 43 | |> binary_part(0, 8) 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/next_ls/registry.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.Registry do 2 | @moduledoc """ 3 | This module includes a version of the `Registry.dispatch/4` function included with the standard library that 4 | does a few of things differently. 5 | 6 | 1. It will execute the callback even if the registry contains no processes for the given key. 7 | 2. The function only works with duplicate registries with a single partition. 8 | 3. The value returned by the callback is returned by the function. 9 | """ 10 | @key_info -2 11 | 12 | def dispatch(registry, key, mfa_or_fun, _opts \\ []) 13 | when is_atom(registry) and is_function(mfa_or_fun, 1) 14 | when is_atom(registry) and tuple_size(mfa_or_fun) == 3 do 15 | case key_info!(registry) do 16 | {:duplicate, 1, key_ets} -> 17 | key_ets 18 | |> safe_lookup_second(key) 19 | |> apply_non_empty_to_mfa_or_fun(mfa_or_fun) 20 | end 21 | end 22 | 23 | defp apply_non_empty_to_mfa_or_fun(entries, {module, function, args}) do 24 | apply(module, function, [entries | args]) 25 | end 26 | 27 | defp apply_non_empty_to_mfa_or_fun(entries, fun) do 28 | fun.(entries) 29 | end 30 | 31 | defp safe_lookup_second(ets, key) do 32 | :ets.lookup_element(ets, key, 2) 33 | catch 34 | :error, :badarg -> [] 35 | end 36 | 37 | defp key_info!(registry) do 38 | :ets.lookup_element(registry, @key_info, 2) 39 | catch 40 | :error, :badarg -> 41 | raise ArgumentError, "unknown registry: #{inspect(registry)}" 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/next_ls/runtime/bundled_elixir.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.Runtime.BundledElixir do 2 | @moduledoc """ 3 | Module to install the bundled Elixir. 4 | 5 | The `@version` attribute corresponds to the last digit in the file name of the zip archive, they need to be incremented in lockstep. 6 | """ 7 | @version 1 8 | @base "~/.cache/elixir-tools/nextls" 9 | @dir "elixir/1-17-#{@version}" 10 | 11 | def binpath(base \\ @base) do 12 | Path.join([base, @dir, "bin"]) 13 | end 14 | 15 | def mixpath(base \\ @base) do 16 | Path.join([binpath(base), "mix"]) 17 | end 18 | 19 | def path(base) do 20 | Path.join([base, @dir]) 21 | end 22 | 23 | def mix_home(base) do 24 | Path.join(path(base), ".mix") 25 | end 26 | 27 | def mix_archives(base) do 28 | Path.join(mix_home(base), "archives") 29 | end 30 | 31 | def install(base, logger) do 32 | mixhome = mix_home(base) 33 | mixarchives = mix_archives(base) 34 | File.mkdir_p!(mixhome) 35 | binpath = binpath(base) 36 | 37 | unless File.exists?(binpath) do 38 | extract_path = path(base) 39 | File.mkdir_p!(base) 40 | 41 | priv_dir = :code.priv_dir(:next_ls) 42 | bundled_elixir_path = ~c"#{Path.join(priv_dir, "precompiled-1-17-#{@version}.zip")}" 43 | 44 | :zip.unzip(bundled_elixir_path, cwd: ~c"#{extract_path}") 45 | 46 | for bin <- Path.wildcard(Path.join(binpath, "*")) do 47 | File.chmod(bin, 0o755) 48 | end 49 | end 50 | 51 | new_path = "#{binpath}:#{System.get_env("PATH")}" 52 | mixbin = mixpath(base) 53 | env = [{"PATH", new_path}, {"MIX_HOME", mixhome}, {"MIX_ARCHIVES", mixarchives}] 54 | 55 | {_, 0} = System.cmd(mixbin, ["local.rebar", "--force"], env: env) 56 | {_, 0} = System.cmd(mixbin, ["local.hex", "--force"], env: env) 57 | 58 | :ok 59 | rescue 60 | e -> 61 | NextLS.Logger.warning(logger, """ 62 | Failed to unzip and install the bundled Elixir archive. 63 | 64 | #{Exception.format(:error, e, __STACKTRACE__)} 65 | """) 66 | 67 | :error 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/next_ls/runtime/sidecar.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.Runtime.Sidecar do 2 | @moduledoc false 3 | use GenServer 4 | 5 | alias NextLS.ASTHelpers.Aliases 6 | alias NextLS.ASTHelpers.Attributes 7 | alias NextLS.DB 8 | 9 | def start_link(args) do 10 | GenServer.start_link(__MODULE__, Keyword.delete(args, :name), Keyword.take(args, [:name])) 11 | end 12 | 13 | def init(args) do 14 | db = Keyword.fetch!(args, :db) 15 | 16 | {:ok, %{db: db}} 17 | end 18 | 19 | def handle_info({:tracer, :dbg, term}, state) do 20 | # credo:disable-for-next-line 21 | dbg(term) 22 | 23 | {:noreply, state} 24 | end 25 | 26 | def handle_info({:tracer, payload}, state) do 27 | attributes = Attributes.get_module_attributes(payload.file, payload.module) 28 | payload = Map.put_new(payload, :symbols, attributes) 29 | DB.insert_symbol(state.db, payload) 30 | 31 | {:noreply, state} 32 | rescue 33 | _ -> 34 | {:noreply, state} 35 | end 36 | 37 | def handle_info({{:tracer, :reference, :alias}, payload}, state) do 38 | # TODO: in the next version of elixir, generated code will not have :column metadata, so we can tell if the alias is from 39 | # a macro. For now, just try and rescue 40 | try do 41 | if payload.meta[:end_of_expression] do 42 | start = %{line: payload.meta[:line], col: payload.meta[:column]} 43 | stop = %{line: payload.meta[:end_of_expression][:line], col: payload.meta[:end_of_expression][:column]} 44 | 45 | {start, stop} = 46 | Aliases.extract_alias_range( 47 | File.read!(payload.file), 48 | {start, stop}, 49 | payload.identifier |> Macro.to_string() |> String.to_atom() 50 | ) 51 | 52 | payload = 53 | payload 54 | |> Map.put(:identifier, payload.module) 55 | |> Map.put(:range, %{start: start, stop: stop}) 56 | 57 | DB.insert_reference(state.db, payload) 58 | end 59 | rescue 60 | _ -> :ok 61 | end 62 | 63 | {:noreply, state} 64 | rescue 65 | _ -> 66 | {:noreply, state} 67 | end 68 | 69 | def handle_info({{:tracer, :reference, :attribute}, payload}, state) do 70 | try do 71 | name = Attributes.get_attribute_reference_name(payload.file, payload.meta[:line], payload.meta[:column]) 72 | if name, do: DB.insert_reference(state.db, %{payload | identifier: name}) 73 | rescue 74 | _ -> :ok 75 | end 76 | 77 | {:noreply, state} 78 | rescue 79 | _ -> 80 | {:noreply, state} 81 | end 82 | 83 | def handle_info({{:tracer, :reference}, payload}, state) do 84 | DB.insert_reference(state.db, payload) 85 | 86 | {:noreply, state} 87 | rescue 88 | _ -> 89 | {:noreply, state} 90 | end 91 | 92 | def handle_info({{:tracer, :start}, filename}, state) do 93 | DB.clean_references(state.db, filename) 94 | {:noreply, state} 95 | rescue 96 | _ -> 97 | {:noreply, state} 98 | end 99 | 100 | def handle_info({{:tracer, :dbg}, payload}, state) do 101 | # credo:disable-for-next-line 102 | dbg(payload) 103 | {:noreply, state} 104 | rescue 105 | _ -> 106 | {:noreply, state} 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/next_ls/runtime/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.Runtime.Supervisor do 2 | @moduledoc false 3 | 4 | use Supervisor 5 | 6 | def start_link(init_arg) do 7 | Supervisor.start_link(__MODULE__, init_arg) 8 | end 9 | 10 | @impl true 11 | def init(init_arg) do 12 | name = init_arg[:name] 13 | lsp = init_arg[:lsp] 14 | lsp_pid = init_arg[:lsp_pid] 15 | registry = init_arg[:registry] 16 | logger = init_arg[:logger] 17 | hidden_folder = init_arg[:path] 18 | File.mkdir_p!(hidden_folder) 19 | File.write!(Path.join(hidden_folder, ".gitignore"), "*\n") 20 | 21 | db_name = :"db-#{name}" 22 | sidecar_name = :"sidecar-#{name}" 23 | db_activity = :"db-activity-#{name}" 24 | 25 | Registry.register(registry, :runtime_supervisors, %{name: name, init_arg: init_arg}) 26 | 27 | children = [ 28 | {NextLS.Runtime.Sidecar, name: sidecar_name, db: db_name}, 29 | {NextLS.DB.Activity, 30 | logger: logger, name: db_activity, lsp: lsp, timeout: Application.get_env(:next_ls, :indexing_timeout)}, 31 | {NextLS.DB, 32 | logger: logger, 33 | file: "#{hidden_folder}/nextls.db", 34 | registry: registry, 35 | name: db_name, 36 | runtime: name, 37 | activity: db_activity}, 38 | {NextLS.Runtime, 39 | init_arg[:runtime] ++ [name: name, registry: registry, parent: sidecar_name, lsp_pid: lsp_pid, db: db_name]} 40 | ] 41 | 42 | Supervisor.init(children, strategy: :one_for_one) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/next_ls/snippet.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.Snippet do 2 | @moduledoc false 3 | 4 | alias GenLSP.Enumerations.CompletionItemKind 5 | alias GenLSP.Enumerations.InsertTextFormat 6 | 7 | def get(label, trigger_character, opts \\ []) 8 | 9 | def get("do", nil, _opts) do 10 | %{ 11 | kind: CompletionItemKind.snippet(), 12 | insert_text_format: InsertTextFormat.snippet(), 13 | insert_text: """ 14 | do 15 | $0 16 | end 17 | """ 18 | } 19 | end 20 | 21 | def get("defmodule/2", nil, opts) do 22 | path = Keyword.get(opts, :uri) 23 | 24 | modulename = 25 | if path do 26 | infer_module_name(path) 27 | else 28 | "ModuleName" 29 | end 30 | 31 | %{ 32 | kind: CompletionItemKind.snippet(), 33 | insert_text_format: InsertTextFormat.snippet(), 34 | insert_text: """ 35 | defmodule ${1:#{modulename}} do 36 | $0 37 | end 38 | """ 39 | } 40 | end 41 | 42 | def get("defstruct/1", nil, _opts) do 43 | %{ 44 | kind: CompletionItemKind.snippet(), 45 | insert_text_format: InsertTextFormat.snippet(), 46 | insert_text: """ 47 | defstruct [${1:field}: ${2:default}] 48 | """ 49 | } 50 | end 51 | 52 | def get("defprotocol/2", nil, _opts) do 53 | %{ 54 | kind: CompletionItemKind.snippet(), 55 | insert_text_format: InsertTextFormat.snippet(), 56 | insert_text: """ 57 | defprotocol ${1:ProtocolName} do 58 | def ${2:function_name}(${3:parameter_name}) 59 | end 60 | """ 61 | } 62 | end 63 | 64 | def get("defimpl/2", nil, _opts) do 65 | %{ 66 | kind: CompletionItemKind.snippet(), 67 | insert_text_format: InsertTextFormat.snippet(), 68 | insert_text: """ 69 | defimpl ${1:ProtocolName} do 70 | def ${2:function_name}(${3:parameter_name}) do 71 | $0 72 | end 73 | end 74 | """ 75 | } 76 | end 77 | 78 | def get("defimpl/3", nil, _opts) do 79 | %{ 80 | kind: CompletionItemKind.snippet(), 81 | insert_text_format: InsertTextFormat.snippet(), 82 | insert_text: """ 83 | defimpl ${1:ProtocolName}, for: ${2:StructName} do 84 | def ${3:function_name}(${4:parameter_name}) do 85 | $0 86 | end 87 | end 88 | """ 89 | } 90 | end 91 | 92 | def get("def/" <> _, nil, _opts) do 93 | %{ 94 | kind: CompletionItemKind.snippet(), 95 | insert_text_format: InsertTextFormat.snippet(), 96 | insert_text: """ 97 | def ${1:function_name}(${2:parameter_1}) do 98 | $0 99 | end 100 | """ 101 | } 102 | end 103 | 104 | def get("defp/" <> _, nil, _opts) do 105 | %{ 106 | kind: CompletionItemKind.snippet(), 107 | insert_text_format: InsertTextFormat.snippet(), 108 | insert_text: """ 109 | defp ${1:function_name}(${2:parameter_1}) do 110 | $0 111 | end 112 | """ 113 | } 114 | end 115 | 116 | def get("defmacro/" <> _, nil, _opts) do 117 | %{ 118 | kind: CompletionItemKind.snippet(), 119 | insert_text_format: InsertTextFormat.snippet(), 120 | insert_text: """ 121 | defmacro ${1:macro_name}(${2:parameter_1}) do 122 | quote do 123 | $0 124 | end 125 | end 126 | """ 127 | } 128 | end 129 | 130 | def get("defmacrop/" <> _, nil, _opts) do 131 | %{ 132 | kind: CompletionItemKind.snippet(), 133 | insert_text_format: InsertTextFormat.snippet(), 134 | insert_text: """ 135 | defmacrop ${1:macro_name}(${2:parameter_1}) do 136 | quote do 137 | $0 138 | end 139 | end 140 | """ 141 | } 142 | end 143 | 144 | def get("for/" <> _, nil, _opts) do 145 | %{ 146 | kind: CompletionItemKind.snippet(), 147 | insert_text_format: InsertTextFormat.snippet(), 148 | insert_text: """ 149 | for ${2:item} <- ${1:enumerable} do 150 | $0 151 | end 152 | """ 153 | } 154 | end 155 | 156 | def get("with/" <> _, nil, _opts) do 157 | %{ 158 | kind: CompletionItemKind.snippet(), 159 | insert_text_format: InsertTextFormat.snippet(), 160 | insert_text: """ 161 | with ${2:match} <- ${1:argument} do 162 | $0 163 | end 164 | """ 165 | } 166 | end 167 | 168 | def get("case/" <> _, nil, _opts) do 169 | %{ 170 | kind: CompletionItemKind.snippet(), 171 | insert_text_format: InsertTextFormat.snippet(), 172 | insert_text: """ 173 | case ${1:argument} do 174 | ${2:match} -> 175 | ${0::ok} 176 | 177 | _ -> 178 | :error 179 | end 180 | """ 181 | } 182 | end 183 | 184 | def get("cond/" <> _, nil, _opts) do 185 | %{ 186 | kind: CompletionItemKind.snippet(), 187 | insert_text_format: InsertTextFormat.snippet(), 188 | insert_text: """ 189 | cond do 190 | ${1:condition} -> 191 | ${0::ok} 192 | 193 | true -> 194 | ${2::error} 195 | end 196 | """ 197 | } 198 | end 199 | 200 | def get("fn/1", nil, _opts) do 201 | %{ 202 | kind: CompletionItemKind.snippet(), 203 | insert_text_format: InsertTextFormat.snippet(), 204 | insert_text: """ 205 | fn $1 -> 206 | $0 207 | end 208 | """ 209 | } 210 | end 211 | 212 | def get("test/2", nil, _opts) do 213 | %{ 214 | kind: CompletionItemKind.snippet(), 215 | insert_text_format: InsertTextFormat.snippet(), 216 | insert_text: """ 217 | test "$1" do 218 | $0 219 | end 220 | """ 221 | } 222 | end 223 | 224 | def get("test/3", nil, _opts) do 225 | %{ 226 | kind: CompletionItemKind.snippet(), 227 | insert_text_format: InsertTextFormat.snippet(), 228 | insert_text: """ 229 | test "$1", %{$2: $3} do 230 | $0 231 | end 232 | """ 233 | } 234 | end 235 | 236 | def get("describe/2", nil, _opts) do 237 | %{ 238 | kind: CompletionItemKind.snippet(), 239 | insert_text_format: InsertTextFormat.snippet(), 240 | insert_text: """ 241 | describe "$1" do 242 | $0 243 | end 244 | """ 245 | } 246 | end 247 | 248 | def get("setup/1", nil, _opts) do 249 | %{ 250 | kind: CompletionItemKind.snippet(), 251 | insert_text_format: InsertTextFormat.snippet(), 252 | insert_text: """ 253 | setup do 254 | $0 255 | end 256 | """ 257 | } 258 | end 259 | 260 | def get("setup/2", nil, _opts) do 261 | %{ 262 | kind: CompletionItemKind.snippet(), 263 | insert_text_format: InsertTextFormat.snippet(), 264 | insert_text: """ 265 | setup ${1:context} do 266 | $0 267 | end 268 | """ 269 | } 270 | end 271 | 272 | def get(_label, _trigger_character, _opts) do 273 | nil 274 | end 275 | 276 | defp infer_module_name(path) do 277 | path 278 | |> Path.rootname() 279 | |> then(fn 280 | "test/support/" <> rest -> rest 281 | "test/" <> rest -> rest 282 | "lib/" <> rest -> rest 283 | path -> path 284 | end) 285 | |> Macro.camelize() 286 | end 287 | end 288 | -------------------------------------------------------------------------------- /lib/next_ls/updater.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.Updater do 2 | @moduledoc false 3 | use Task 4 | 5 | def start_link(arg \\ []) do 6 | Task.start_link(__MODULE__, :run, [arg]) 7 | end 8 | 9 | def run(opts) do 10 | Logger.put_module_level(Req.Steps, :none) 11 | 12 | binpath = Keyword.get(opts, :binpath, Path.expand("~/.cache/elixir-tools/nextls/bin/nextls")) 13 | api_host = Keyword.get(opts, :api_host, "https://api.github.com") 14 | github_host = Keyword.get(opts, :github_host, "https://github.com") 15 | logger = Keyword.fetch!(opts, :logger) 16 | current_version = Keyword.fetch!(opts, :current_version) 17 | retry = Keyword.get(opts, :retry, :safe_transient) 18 | 19 | case Req.get("/repos/elixir-tools/next-ls/releases/latest", base_url: api_host, retry: retry) do 20 | {:ok, %{status: 200, body: %{"tag_name" => "v" <> version = tag}}} -> 21 | with {:ok, latest_version} <- Version.parse(version), 22 | :gt <- Version.compare(latest_version, current_version) do 23 | with :ok <- File.rename(binpath, binpath <> "-#{Version.to_string(current_version)}"), 24 | {:error, error} <- 25 | Req.get("/elixir-tools/next-ls/releases/download/#{tag}/next_ls_#{os()}_#{arch()}", 26 | into: File.stream!(binpath), 27 | base_url: github_host, 28 | retry: retry 29 | ) do 30 | NextLS.Logger.show_message(logger, :error, "Failed to download version #{version} of Next LS!") 31 | NextLS.Logger.error(logger, "Failed to download Next LS: #{inspect(error)}") 32 | :error 33 | end 34 | 35 | File.chmod(binpath, 0o755) 36 | 37 | NextLS.Logger.show_message( 38 | logger, 39 | :info, 40 | "[Next LS] Downloaded v#{version}, please restart your editor for it to take effect." 41 | ) 42 | 43 | NextLS.Logger.info(logger, "Downloaded #{version} of Next LS") 44 | end 45 | 46 | {_, error} -> 47 | NextLS.Logger.error( 48 | logger, 49 | "Failed to retrieve the latest version number of Next LS from the GitHub API: #{inspect(error)}" 50 | ) 51 | end 52 | end 53 | 54 | defp arch do 55 | arch_str = :erlang.system_info(:system_architecture) 56 | [arch | _] = arch_str |> List.to_string() |> String.split("-") 57 | 58 | case {:os.type(), arch, :erlang.system_info(:wordsize) * 8} do 59 | {{:win32, _}, _arch, 64} -> :amd64 60 | {_os, arch, 64} when arch in ~w(arm aarch64) -> :arm64 61 | {_os, arch, 64} when arch in ~w(amd64 x86_64) -> :amd64 62 | {os, arch, _wordsize} -> raise "Unsupported system: os=#{inspect(os)}, arch=#{inspect(arch)}" 63 | end 64 | end 65 | 66 | defp os do 67 | case :os.type() do 68 | {:win32, _} -> :windows 69 | {:unix, :darwin} -> :darwin 70 | {:unix, :linux} -> :linux 71 | unknown_os -> raise "Unsupported system: os=#{inspect(unknown_os)}}" 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule NextLS.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.23.3" # x-release-please-version 5 | 6 | def project do 7 | [ 8 | app: :next_ls, 9 | description: 10 | "The language server for Elixir that just works. No longer published to Hex, please see our GitHub Releases for downloads.", 11 | version: @version, 12 | elixir: "~> 1.13", 13 | elixirc_paths: elixirc_paths(Mix.env()), 14 | start_permanent: Mix.env() == :prod, 15 | releases: releases(), 16 | default_release: :next_ls, 17 | package: package(), 18 | deps: deps(), 19 | docs: [ 20 | # The main page in the docs 21 | main: "README", 22 | extras: ["README.md"] 23 | ], 24 | dialyzer: [ 25 | plt_core_path: "priv/plts", 26 | plt_local_path: "priv/plts", 27 | ignore_warnings: ".dialyzer_ignore.exs" 28 | ] 29 | ] 30 | end 31 | 32 | # Run "mix help compile.app" to learn about applications. 33 | def application do 34 | [ 35 | extra_applications: [:logger, :crypto], 36 | mod: {NextLS.Application, []} 37 | ] 38 | end 39 | 40 | def releases do 41 | [ 42 | plain: [], 43 | next_ls: [ 44 | steps: [:assemble, &Burrito.wrap/1], 45 | burrito: [ 46 | targets: 47 | inject_custom_erts( 48 | darwin_arm64: [os: :darwin, cpu: :aarch64], 49 | darwin_amd64: [os: :darwin, cpu: :x86_64], 50 | linux_arm64: [os: :linux, cpu: :aarch64], 51 | linux_amd64: [os: :linux, cpu: :x86_64], 52 | windows_amd64: [os: :windows, cpu: :x86_64] 53 | ) 54 | ] 55 | ] 56 | ] 57 | end 58 | 59 | defp elixirc_paths(:test), do: ["lib", "test/support"] 60 | defp elixirc_paths(_), do: ["lib"] 61 | 62 | # Run "mix help deps" to learn about dependencies. 63 | defp deps do 64 | [ 65 | {:exqlite, "~> 0.13.14"}, 66 | {:gen_lsp, "~> 0.10"}, 67 | # {:gen_lsp, path: "../gen_lsp"}, 68 | {:req, "~> 0.3"}, 69 | {:schematic, "~> 0.2"}, 70 | {:spitfire, github: "elixir-tools/spitfire"}, 71 | # {:spitfire, path: "../spitfire"}, 72 | {:sourceror, "~> 1.0"}, 73 | {:opentelemetry, "~> 1.3"}, 74 | {:opentelemetry_api, "~> 1.2"}, 75 | {:opentelemetry_exporter, "~> 1.4"}, 76 | {:opentelemetry_process_propagator, "~> 0.2.2"}, 77 | {:opentelemetry_telemetry, "~> 1.0"}, 78 | {:burrito, "~> 1.0", only: [:dev, :prod]}, 79 | {:bypass, "~> 2.1", only: :test}, 80 | {:dialyxir, ">= 0.0.0", only: [:dev, :test], runtime: false}, 81 | {:credo, "~> 1.7.7", only: [:dev, :test], runtime: false}, 82 | {:ex_doc, ">= 0.0.0", only: :dev}, 83 | {:styler, "~> 1.0-rc.0", only: :dev} 84 | ] 85 | end 86 | 87 | defp package do 88 | [ 89 | maintainers: ["Mitchell Hanberg"], 90 | licenses: ["MIT"], 91 | links: %{ 92 | GitHub: "https://github.com/elixir-tools/next-ls", 93 | Sponsor: "https://github.com/sponsors/mhanberg", 94 | Downloads: "https://github.com/elixir-tools/next-ls/releases" 95 | }, 96 | files: ~w(lib LICENSE mix.exs priv README.md .formatter.exs) 97 | ] 98 | end 99 | 100 | defp inject_custom_erts(targets) do 101 | # By default, Burrito downloads ERTS from https://burrito-otp.b-cdn.net. 102 | # When building with Nix, side-effects like network access are not allowed, 103 | # so we need to inject our own ERTS path. 104 | 105 | erts_path = System.get_env("BURRITO_ERTS_PATH", "") 106 | 107 | Enum.map(targets, fn {target_name, target_conf} -> 108 | case erts_path do 109 | "" -> {target_name, target_conf} 110 | path -> {target_name, [{:custom_erts, path} | target_conf]} 111 | end 112 | end) 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /otel-collector.yaml: -------------------------------------------------------------------------------- 1 | receivers: 2 | otlp: 3 | protocols: 4 | grpc: 5 | exporters: 6 | otlp: 7 | endpoint: tempo:4317 8 | tls: 9 | insecure: true 10 | service: 11 | pipelines: 12 | traces: 13 | receivers: [otlp] 14 | exporters: [otlp] 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "commitlint": "commitlint" 4 | }, 5 | "dependencies": { 6 | "@commitlint/config-conventional": "^18.1.0", 7 | "commitlint": "^18.2.0" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /package.nix: -------------------------------------------------------------------------------- 1 | { 2 | lib, 3 | beamPackages, 4 | elixir, 5 | }: 6 | 7 | beamPackages.mixRelease rec { 8 | pname = "next-ls"; 9 | src = ./.; 10 | mixEnv = "prod"; 11 | removeCookie = false; 12 | version = "0.23.3"; # x-release-please-version 13 | 14 | inherit elixir; 15 | inherit (beamPackages) erlang; 16 | 17 | mixFodDeps = beamPackages.fetchMixDeps { 18 | inherit src version elixir; 19 | pname = "next-ls-deps"; 20 | hash = "sha256-4Rt5Q0fX+fbncvxyXdpIhgEvn9VYX/QDxDdnbanT21Q="; 21 | mixEnv = "prod"; 22 | }; 23 | 24 | installPhase = '' 25 | mix release --no-deps-check --path $out plain 26 | echo "$out/bin/plain eval \"System.no_halt(true); Application.ensure_all_started(:next_ls)\" \"\$@\"" > "$out/bin/nextls" 27 | chmod +x "$out/bin/nextls" 28 | ''; 29 | 30 | meta = with lib; { 31 | license = licenses.mit; 32 | homepage = "https://www.elixir-tools.dev/next-ls/"; 33 | description = "The language server for Elixir that just works"; 34 | mainProgram = "nextls"; 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /priv/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-tools/next-ls/eec6d57237cffd70dadf09c0504880dbe9ac07d7/priv/.keep -------------------------------------------------------------------------------- /priv/cmd: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Start the program in the background 4 | exec "$@" & 5 | pid1=$! 6 | 7 | # Silence warnings from here on 8 | exec >/dev/null 2>&1 9 | 10 | # Read from stdin in the background and 11 | # kill running program when stdin closes 12 | exec 0<&0 $( 13 | while read; do :; done 14 | kill -KILL $pid1 15 | ) & 16 | pid2=$! 17 | 18 | # Clean up 19 | wait $pid1 20 | ret=$? 21 | kill -KILL $pid2 22 | exit $ret 23 | -------------------------------------------------------------------------------- /priv/monkey/_next_ls_private_credo.ex: -------------------------------------------------------------------------------- 1 | defmodule :_next_ls_private_credo do 2 | @moduledoc false 3 | 4 | def issues(args, dir) do 5 | args 6 | |> Kernel.++(["--working-dir", dir]) 7 | |> Credo.run() 8 | |> Credo.Execution.get_issues() 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /priv/plts/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-tools/next-ls/eec6d57237cffd70dadf09c0504880dbe9ac07d7/priv/plts/.keep -------------------------------------------------------------------------------- /priv/precompiled-1-17-1.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-tools/next-ls/eec6d57237cffd70dadf09c0504880dbe9ac07d7/priv/precompiled-1-17-1.zip -------------------------------------------------------------------------------- /prometheus.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 15s 3 | evaluation_interval: 15s 4 | 5 | scrape_configs: 6 | - job_name: 'prometheus' 7 | static_configs: 8 | - targets: [ 'localhost:9090' ] 9 | - job_name: 'tempo' 10 | static_configs: 11 | - targets: [ 'tempo:3200' ] 12 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", 3 | "packages": { 4 | ".": { 5 | "package-name": "next_ls", 6 | "release-type": "elixir", 7 | "bump-minor-pre-major": true, 8 | "include-component-in-tag": false, 9 | "extra-files": [ 10 | "package.nix" 11 | ] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tempo.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | http_listen_port: 3200 3 | 4 | distributor: 5 | receivers: # this configuration will listen on all ports and protocols that tempo is capable of. 6 | jaeger: # the receives all come from the OpenTelemetry collector. more configuration information can 7 | protocols: # be found there: https://github.com/open-telemetry/opentelemetry-collector/tree/main/receiver 8 | thrift_http: # 9 | grpc: # for a production deployment you should only enable the receivers you need! 10 | thrift_binary: 11 | thrift_compact: 12 | zipkin: 13 | otlp: 14 | protocols: 15 | http: 16 | grpc: 17 | opencensus: 18 | 19 | ingester: 20 | max_block_duration: 5m # cut the headblock when this much time passes. this is being set for demo purposes and should probably be left alone normally 21 | 22 | compactor: 23 | compaction: 24 | block_retention: 1h # overall Tempo trace retention. set for demo purposes 25 | 26 | metrics_generator: 27 | registry: 28 | external_labels: 29 | source: tempo 30 | cluster: docker-compose 31 | storage: 32 | path: /tmp/tempo/generator/wal 33 | remote_write: 34 | - url: http://prometheus:9090/api/v1/write 35 | send_exemplars: true 36 | 37 | storage: 38 | trace: 39 | backend: local # backend configuration to use 40 | wal: 41 | path: /tmp/tempo/wal # where to store the the wal locally 42 | local: 43 | path: /tmp/tempo/blocks 44 | 45 | overrides: 46 | metrics_generator_processors: [service-graphs, span-metrics] # enables metrics generator 47 | -------------------------------------------------------------------------------- /test/next_ls/alias_test.exs: -------------------------------------------------------------------------------- 1 | defmodule NextLS.AliasTest do 2 | use ExUnit.Case, async: true 3 | 4 | import GenLSP.Test 5 | import NextLS.Support.Utils 6 | 7 | @moduletag :tmp_dir 8 | @moduletag root_paths: ["my_proj"] 9 | 10 | setup %{tmp_dir: tmp_dir} do 11 | File.mkdir_p!(Path.join(tmp_dir, "my_proj/lib")) 12 | File.write!(Path.join(tmp_dir, "my_proj/mix.exs"), mix_exs()) 13 | 14 | cwd = Path.join(tmp_dir, "my_proj") 15 | 16 | foo_path = Path.join(cwd, "lib/foo.ex") 17 | 18 | foo = """ 19 | defmodule Foo do 20 | def to_list() do 21 | Foo.Bar.to_list(Map.new()) 22 | end 23 | 24 | def to_map() do 25 | Foo.Bar.to_map(List.new()) 26 | end 27 | end 28 | """ 29 | 30 | File.write!(foo_path, foo) 31 | 32 | [foo: foo, foo_path: foo_path] 33 | end 34 | 35 | setup :with_lsp 36 | 37 | setup context do 38 | assert :ok == notify(context.client, %{method: "initialized", jsonrpc: "2.0", params: %{}}) 39 | assert_is_ready(context, "my_proj") 40 | assert_compiled(context, "my_proj") 41 | assert_notification "$/progress", %{"value" => %{"kind" => "end", "message" => "Finished indexing!"}} 42 | 43 | did_open(context.client, context.foo_path, context.foo) 44 | context 45 | end 46 | 47 | test "refactors with alias", %{client: client, foo_path: foo} do 48 | foo_uri = uri(foo) 49 | id = 1 50 | 51 | request client, %{ 52 | method: "workspace/executeCommand", 53 | id: id, 54 | jsonrpc: "2.0", 55 | params: %{ 56 | command: "alias-refactor", 57 | arguments: [%{uri: foo_uri, position: %{line: 2, character: 8}}] 58 | } 59 | } 60 | 61 | expected_edit = 62 | String.trim(""" 63 | defmodule Foo do 64 | alias Foo.Bar 65 | 66 | def to_list() do 67 | Bar.to_list(Map.new()) 68 | end 69 | 70 | def to_map() do 71 | Bar.to_map(List.new()) 72 | end 73 | end 74 | """) 75 | 76 | assert_request(client, "workspace/applyEdit", 500, fn params -> 77 | assert %{"edit" => edit, "label" => "Refactored with an alias"} = params 78 | 79 | assert %{ 80 | "changes" => %{ 81 | ^foo_uri => [%{"newText" => text, "range" => range}] 82 | } 83 | } = edit 84 | 85 | assert text == expected_edit 86 | 87 | assert range["start"] == %{"character" => 0, "line" => 0} 88 | assert range["end"] == %{"character" => 3, "line" => 8} 89 | end) 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /test/next_ls/diagnostics_test.exs: -------------------------------------------------------------------------------- 1 | defmodule NextLS.DiagnosticsTest do 2 | use ExUnit.Case, async: true 3 | 4 | import GenLSP.Test 5 | import NextLS.Support.Utils 6 | 7 | @moduletag :tmp_dir 8 | @moduletag root_paths: ["my_proj"] 9 | setup %{tmp_dir: tmp_dir} do 10 | File.mkdir_p!(Path.join(tmp_dir, "my_proj/lib")) 11 | File.write!(Path.join(tmp_dir, "my_proj/mix.exs"), mix_exs()) 12 | [cwd: tmp_dir] 13 | end 14 | 15 | setup %{tmp_dir: tmp_dir} do 16 | File.write!(Path.join(tmp_dir, "my_proj/lib/bar.ex"), """ 17 | defmodule Bar do 18 | defstruct [:foo] 19 | 20 | def foo(arg1) do 21 | end 22 | end 23 | """) 24 | 25 | File.write!(Path.join(tmp_dir, "my_proj/lib/code_action.ex"), """ 26 | defmodule Foo.CodeAction do 27 | # some comment 28 | 29 | defmodule NestedMod do 30 | def foo do 31 | :ok 32 | end 33 | end 34 | end 35 | """) 36 | 37 | File.write!(Path.join(tmp_dir, "my_proj/lib/foo.ex"), """ 38 | defmodule Foo do 39 | end 40 | """) 41 | 42 | File.write!(Path.join(tmp_dir, "my_proj/lib/project.ex"), """ 43 | defmodule Project do 44 | def hello do 45 | :world 46 | end 47 | end 48 | """) 49 | 50 | File.rm_rf!(Path.join(tmp_dir, ".elixir-tools")) 51 | 52 | :ok 53 | end 54 | 55 | setup :with_lsp 56 | 57 | test "publishes diagnostics once the client has initialized", %{client: client, cwd: cwd} = context do 58 | assert :ok == notify(client, %{method: "initialized", jsonrpc: "2.0", params: %{}}) 59 | 60 | assert_notification "window/logMessage", %{ 61 | "message" => "[Next LS] NextLS v" <> _, 62 | "type" => 4 63 | } 64 | 65 | title = "Initializing NextLS runtime for folder #{context.module}-my_proj..." 66 | 67 | assert_notification "$/progress", %{ 68 | "value" => %{"kind" => "begin", "title" => ^title} 69 | } 70 | 71 | message = "NextLS runtime for folder #{context.module}-my_proj has initialized!" 72 | 73 | assert_notification "$/progress", %{ 74 | "value" => %{ 75 | "kind" => "end", 76 | "message" => ^message 77 | } 78 | } 79 | 80 | assert_compiled(context, "my_proj") 81 | 82 | for file <- ["bar.ex"] do 83 | uri = 84 | to_string(%URI{ 85 | host: "", 86 | scheme: "file", 87 | path: Path.join([cwd, "my_proj/lib", file]) 88 | }) 89 | 90 | char = if Version.match?(System.version(), ">= 1.15.0"), do: 10, else: 0 91 | 92 | assert_notification "textDocument/publishDiagnostics", %{ 93 | "uri" => ^uri, 94 | "diagnostics" => [ 95 | %{ 96 | "source" => "Elixir", 97 | "severity" => 2, 98 | "message" => 99 | "variable \"arg1\" is unused (if the variable is not meant to be used, prefix it with an underscore)", 100 | "range" => %{ 101 | "start" => %{"line" => 3, "character" => ^char}, 102 | "end" => %{"line" => 3, "character" => 14} 103 | }, 104 | "data" => %{"type" => "unused_variable", "namespace" => "elixir"} 105 | } 106 | ] 107 | } 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /test/next_ls/extensions/credo_extension_test.exs: -------------------------------------------------------------------------------- 1 | defmodule NextLS.CredoExtensionTest do 2 | # this test installs and compiles credo from scratch everytime it runs 3 | # we need to determine a way to cache this without losing the utility of 4 | # the test. 5 | use ExUnit.Case, async: true 6 | 7 | import GenLSP.Test 8 | import NextLS.Support.Utils 9 | 10 | @moduletag :tmp_dir 11 | @moduletag root_paths: ["my_proj"] 12 | 13 | setup %{tmp_dir: tmp_dir} do 14 | File.mkdir_p!(Path.join(tmp_dir, "my_proj/lib")) 15 | File.write!(Path.join(tmp_dir, "my_proj/mix.exs"), proj_mix_exs()) 16 | 17 | [cwd: tmp_dir] 18 | end 19 | 20 | setup %{cwd: cwd} do 21 | foo = Path.join(cwd, "my_proj/lib/foo.ex") 22 | 23 | File.write!(foo, """ 24 | defmodule Foo do 25 | def run() do 26 | dbg(:ok) 27 | end 28 | end 29 | """) 30 | 31 | credo = Path.join(cwd, "my_proj/.credo.exs") 32 | 33 | File.write!(credo, ~S""" 34 | %{ 35 | configs: [ 36 | %{ 37 | name: "default", 38 | files: %{ 39 | included: ["lib/", "test/"], 40 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 41 | }, 42 | plugins: [], 43 | requires: [], 44 | strict: false, 45 | parse_timeout: 5000, 46 | color: true, 47 | checks: %{ 48 | enabled: [ 49 | {Credo.Check.Readability.ModuleDoc, []}, 50 | {Credo.Check.Warning.Dbg, []}, 51 | {Credo.Check.Warning.IoInspect, []}, 52 | ], 53 | disabled: [] 54 | } 55 | } 56 | ] 57 | } 58 | """) 59 | 60 | [foo: foo] 61 | end 62 | 63 | setup %{cwd: cwd} do 64 | assert {_, 0} = System.cmd("mix", ["deps.get"], cd: Path.join(cwd, "my_proj")) 65 | :ok 66 | end 67 | 68 | setup :with_lsp 69 | 70 | @tag init_options: %{"extensions" => %{"credo" => %{"enable" => false}}} 71 | test "disables Credo", %{client: client} = context do 72 | assert :ok == notify(client, %{method: "initialized", jsonrpc: "2.0", params: %{}}) 73 | 74 | assert_is_ready(context, "my_proj") 75 | assert_compiled(context, "my_proj") 76 | 77 | assert_notification "window/logMessage", %{ 78 | "message" => "[Next LS] [extension] Credo disabled", 79 | "type" => 3 80 | } 81 | end 82 | 83 | @tag init_options: %{"extensions" => %{"credo" => %{"cli_options" => ["--only", "warning"]}}} 84 | test "configures cli options", %{client: client, foo: foo} = context do 85 | assert :ok == notify(client, %{method: "initialized", jsonrpc: "2.0", params: %{}}) 86 | 87 | assert_is_ready(context, "my_proj") 88 | assert_compiled(context, "my_proj") 89 | 90 | assert_notification "window/logMessage", %{ 91 | "message" => "[Next LS] [extension] Credo initializing with options" <> _, 92 | "type" => 3 93 | } 94 | 95 | uri = uri(foo) 96 | 97 | assert_notification "textDocument/publishDiagnostics", %{ 98 | "uri" => ^uri, 99 | "diagnostics" => [ 100 | %{ 101 | "code" => "Credo.Check.Warning.Dbg", 102 | "codeDescription" => %{ 103 | "href" => "https://hexdocs.pm/credo/Credo.Check.Warning.Dbg.html" 104 | }, 105 | "data" => %{ 106 | "check" => "Elixir.Credo.Check.Warning.Dbg", 107 | "file" => "lib/foo.ex" 108 | }, 109 | "message" => "There should be no calls to `dbg/1`.", 110 | "range" => %{ 111 | "end" => %{"character" => 7, "line" => 2}, 112 | "start" => %{"character" => 4, "line" => 2} 113 | }, 114 | "severity" => 2, 115 | "source" => "credo" 116 | } 117 | ] 118 | } 119 | end 120 | 121 | test "publishes credo diagnostics", %{client: client, foo: foo} = context do 122 | assert :ok == notify(client, %{method: "initialized", jsonrpc: "2.0", params: %{}}) 123 | 124 | assert_is_ready(context, "my_proj") 125 | assert_compiled(context, "my_proj") 126 | assert_notification "$/progress", %{"value" => %{"kind" => "end", "message" => "Finished indexing!"}} 127 | 128 | assert_notification "window/logMessage", %{ 129 | "message" => "[Next LS] [extension] Credo initializing with options" <> _, 130 | "type" => 3 131 | } 132 | 133 | uri = uri(foo) 134 | 135 | assert_notification "textDocument/publishDiagnostics", %{ 136 | "uri" => ^uri, 137 | "diagnostics" => [ 138 | %{ 139 | "code" => "Credo.Check.Warning.Dbg", 140 | "codeDescription" => %{ 141 | "href" => "https://hexdocs.pm/credo/Credo.Check.Warning.Dbg.html" 142 | }, 143 | "data" => %{ 144 | "check" => "Elixir.Credo.Check.Warning.Dbg", 145 | "file" => "lib/foo.ex" 146 | }, 147 | "message" => "There should be no calls to `dbg/1`.", 148 | "range" => %{ 149 | "end" => %{"character" => 7, "line" => 2}, 150 | "start" => %{"character" => 4, "line" => 2} 151 | }, 152 | "severity" => 2, 153 | "source" => "credo" 154 | }, 155 | %{ 156 | "code" => "Credo.Check.Readability.ModuleDoc", 157 | "codeDescription" => %{ 158 | "href" => "https://hexdocs.pm/credo/Credo.Check.Readability.ModuleDoc.html" 159 | }, 160 | "data" => %{ 161 | "check" => "Elixir.Credo.Check.Readability.ModuleDoc", 162 | "file" => "lib/foo.ex" 163 | }, 164 | "message" => "Modules should have a @moduledoc tag.", 165 | "range" => %{ 166 | "end" => %{"character" => 13, "line" => 0}, 167 | "start" => %{"character" => 10, "line" => 0} 168 | }, 169 | "severity" => 3, 170 | "source" => "credo" 171 | } 172 | ] 173 | } 174 | end 175 | 176 | defp proj_mix_exs do 177 | """ 178 | defmodule MyProj.MixProject do 179 | use Mix.Project 180 | 181 | def project do 182 | [ 183 | app: :my_proj, 184 | version: "0.1.0", 185 | elixir: "~> 1.10", 186 | deps: [ 187 | {:credo, "~> 1.7"}, 188 | {:jason, github: "mhanberg/jason", branch: "format", override: true} 189 | ] 190 | ] 191 | end 192 | end 193 | """ 194 | end 195 | end 196 | -------------------------------------------------------------------------------- /test/next_ls/extensions/elixir_extension/code_action/require_test.exs: -------------------------------------------------------------------------------- 1 | defmodule NextLS.ElixirExtension.RequireTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias GenLSP.Structures.CodeAction 5 | alias GenLSP.Structures.Diagnostic 6 | alias GenLSP.Structures.Position 7 | alias GenLSP.Structures.Range 8 | alias GenLSP.Structures.TextEdit 9 | alias GenLSP.Structures.WorkspaceEdit 10 | alias NextLS.ElixirExtension.CodeAction.Require 11 | 12 | test "adds require to module" do 13 | text = 14 | String.split( 15 | """ 16 | defmodule Test.Require do 17 | def hello() do 18 | Logger.info("foo") 19 | end 20 | end 21 | """, 22 | "\n" 23 | ) 24 | 25 | start = %Position{character: 11, line: 2} 26 | 27 | diagnostic = %Diagnostic{ 28 | data: %{"namespace" => "elixir", "type" => "require"}, 29 | message: "you must require Logger before invoking the macro Logger.info/1", 30 | source: "Elixir", 31 | range: %GenLSP.Structures.Range{ 32 | start: start, 33 | end: %{start | character: 999} 34 | } 35 | } 36 | 37 | uri = "file:///home/owner/my_project/hello.ex" 38 | 39 | assert [code_action] = Require.new(diagnostic, text, uri) 40 | assert is_struct(code_action, CodeAction) 41 | assert [diagnostic] == code_action.diagnostics 42 | assert code_action.title == "Add missing require for Logger" 43 | 44 | edit_position = %GenLSP.Structures.Position{line: 1, character: 0} 45 | 46 | assert %WorkspaceEdit{ 47 | changes: %{ 48 | ^uri => [ 49 | %TextEdit{ 50 | new_text: " require Logger\n", 51 | range: %Range{start: ^edit_position, end: ^edit_position} 52 | } 53 | ] 54 | } 55 | } = code_action.edit 56 | end 57 | 58 | test "adds require after moduledoc" do 59 | text = 60 | String.split( 61 | """ 62 | defmodule Test.Require do 63 | @moduledoc 64 | def hello() do 65 | Logger.info("foo") 66 | end 67 | end 68 | """, 69 | "\n" 70 | ) 71 | 72 | start = %Position{character: 0, line: 2} 73 | 74 | diagnostic = %Diagnostic{ 75 | data: %{"namespace" => "elixir", "type" => "require"}, 76 | message: "you must require Logger before invoking the macro Logger.info/1", 77 | source: "Elixir", 78 | range: %GenLSP.Structures.Range{ 79 | start: start, 80 | end: %{start | character: 999} 81 | } 82 | } 83 | 84 | uri = "file:///home/owner/my_project/hello.ex" 85 | 86 | assert [code_action] = Require.new(diagnostic, text, uri) 87 | assert is_struct(code_action, CodeAction) 88 | assert [diagnostic] == code_action.diagnostics 89 | assert code_action.title == "Add missing require for Logger" 90 | 91 | assert %WorkspaceEdit{ 92 | changes: %{ 93 | ^uri => [ 94 | %TextEdit{ 95 | new_text: " require Logger\n", 96 | range: %Range{start: ^start, end: ^start} 97 | } 98 | ] 99 | } 100 | } = code_action.edit 101 | end 102 | 103 | test "adds require after alias" do 104 | text = 105 | String.split( 106 | """ 107 | defmodule Test.Require do 108 | @moduledoc 109 | import Test.Foo 110 | alias Test.Bar 111 | def hello() do 112 | Logger.info("foo") 113 | end 114 | end 115 | """, 116 | "\n" 117 | ) 118 | 119 | start = %Position{character: 0, line: 4} 120 | 121 | diagnostic = %Diagnostic{ 122 | data: %{"namespace" => "elixir", "type" => "require"}, 123 | message: "you must require Logger before invoking the macro Logger.info/1", 124 | source: "Elixir", 125 | range: %GenLSP.Structures.Range{ 126 | start: start, 127 | end: %{start | character: 999} 128 | } 129 | } 130 | 131 | uri = "file:///home/owner/my_project/hello.ex" 132 | 133 | assert [code_action] = Require.new(diagnostic, text, uri) 134 | assert is_struct(code_action, CodeAction) 135 | assert [diagnostic] == code_action.diagnostics 136 | assert code_action.title == "Add missing require for Logger" 137 | 138 | assert %WorkspaceEdit{ 139 | changes: %{ 140 | ^uri => [ 141 | %TextEdit{ 142 | new_text: " require Logger\n", 143 | range: %Range{start: ^start, end: ^start} 144 | } 145 | ] 146 | } 147 | } = code_action.edit 148 | end 149 | 150 | test "figures out the correct module" do 151 | text = 152 | String.split( 153 | """ 154 | defmodule Test do 155 | defmodule Foo do 156 | def hello() do 157 | IO.inspect("foo") 158 | end 159 | end 160 | 161 | defmodule Require do 162 | @moduledoc 163 | import Test.Foo 164 | alias Test.Bar 165 | 166 | def hello() do 167 | Logger.info("foo") 168 | end 169 | end 170 | end 171 | """, 172 | "\n" 173 | ) 174 | 175 | start = %Position{character: 0, line: 11} 176 | 177 | diagnostic = %Diagnostic{ 178 | data: %{"namespace" => "elixir", "type" => "require"}, 179 | message: "you must require Logger before invoking the macro Logger.info/1", 180 | source: "Elixir", 181 | range: %GenLSP.Structures.Range{ 182 | start: start, 183 | end: %{start | character: 999} 184 | } 185 | } 186 | 187 | uri = "file:///home/owner/my_project/hello.ex" 188 | 189 | assert [code_action] = Require.new(diagnostic, text, uri) 190 | assert is_struct(code_action, CodeAction) 191 | assert [diagnostic] == code_action.diagnostics 192 | assert code_action.title == "Add missing require for Logger" 193 | 194 | assert %WorkspaceEdit{ 195 | changes: %{ 196 | ^uri => [ 197 | %TextEdit{ 198 | new_text: " require Logger\n", 199 | range: %Range{start: ^start, end: ^start} 200 | } 201 | ] 202 | } 203 | } = code_action.edit 204 | end 205 | end 206 | -------------------------------------------------------------------------------- /test/next_ls/extensions/elixir_extension/code_action/undefined_function_test.exs: -------------------------------------------------------------------------------- 1 | defmodule NextLS.ElixirExtension.UndefinedFunctionTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias GenLSP.Structures.CodeAction 5 | alias GenLSP.Structures.Diagnostic 6 | alias GenLSP.Structures.Position 7 | alias GenLSP.Structures.Range 8 | alias GenLSP.Structures.TextEdit 9 | alias GenLSP.Structures.WorkspaceEdit 10 | alias NextLS.ElixirExtension.CodeAction.UndefinedFunction 11 | 12 | test "in outer module creates new private function inside current module" do 13 | text = 14 | String.split( 15 | """ 16 | defmodule Test.Foo do 17 | defmodule Bar do 18 | def run() do 19 | :ok 20 | end 21 | end 22 | 23 | def hello() do 24 | bar(1, 2) 25 | end 26 | 27 | defmodule Baz do 28 | def run() do 29 | :error 30 | end 31 | end 32 | end 33 | """, 34 | "\n" 35 | ) 36 | 37 | start = %Position{character: 4, line: 8} 38 | 39 | diagnostic = %Diagnostic{ 40 | data: %{ 41 | "namespace" => "elixir", 42 | "type" => "undefined-function", 43 | "info" => %{ 44 | "name" => "bar", 45 | "arity" => "2", 46 | "module" => "Elixir.Test.Foo" 47 | } 48 | }, 49 | message: 50 | "undefined function bar/2 (expected Test.Foo to define such a function or for it to be imported, but none are available)", 51 | source: "Elixir", 52 | range: %GenLSP.Structures.Range{ 53 | start: start, 54 | end: %{start | character: 6} 55 | } 56 | } 57 | 58 | uri = "file:///home/owner/my_project/hello.ex" 59 | 60 | assert [public, private] = UndefinedFunction.new(diagnostic, text, uri) 61 | assert [diagnostic] == public.diagnostics 62 | assert public.title == "Create public function bar/2" 63 | 64 | edit_position = %Position{line: 16, character: 0} 65 | 66 | assert %WorkspaceEdit{ 67 | changes: %{ 68 | ^uri => [ 69 | %TextEdit{ 70 | new_text: """ 71 | 72 | def bar(param1, param2) do 73 | 74 | end 75 | """, 76 | range: %Range{start: ^edit_position, end: ^edit_position} 77 | } 78 | ] 79 | } 80 | } = public.edit 81 | 82 | assert [diagnostic] == private.diagnostics 83 | assert private.title == "Create private function bar/2" 84 | 85 | edit_position = %Position{line: 16, character: 0} 86 | 87 | assert %WorkspaceEdit{ 88 | changes: %{ 89 | ^uri => [ 90 | %TextEdit{ 91 | new_text: """ 92 | 93 | defp bar(param1, param2) do 94 | 95 | end 96 | """, 97 | range: %Range{start: ^edit_position, end: ^edit_position} 98 | } 99 | ] 100 | } 101 | } = private.edit 102 | end 103 | 104 | test "in inner module creates new private function inside current module" do 105 | text = 106 | String.split( 107 | """ 108 | defmodule Test.Foo do 109 | defmodule Bar do 110 | def run() do 111 | bar(1, 2) 112 | end 113 | end 114 | 115 | defmodule Baz do 116 | def run() do 117 | :error 118 | end 119 | end 120 | end 121 | """, 122 | "\n" 123 | ) 124 | 125 | start = %Position{character: 6, line: 3} 126 | 127 | diagnostic = %Diagnostic{ 128 | data: %{ 129 | "namespace" => "elixir", 130 | "type" => "undefined-function", 131 | "info" => %{ 132 | "name" => "bar", 133 | "arity" => "2", 134 | "module" => "Elixir.Test.Foo.Bar" 135 | } 136 | }, 137 | message: 138 | "undefined function bar/2 (expected Test.Foo to define such a function or for it to be imported, but none are available)", 139 | source: "Elixir", 140 | range: %GenLSP.Structures.Range{ 141 | start: start, 142 | end: %{start | character: 9} 143 | } 144 | } 145 | 146 | uri = "file:///home/owner/my_project/hello.ex" 147 | 148 | assert [_, code_action] = UndefinedFunction.new(diagnostic, text, uri) 149 | assert %CodeAction{} = code_action 150 | assert [diagnostic] == code_action.diagnostics 151 | assert code_action.title == "Create private function bar/2" 152 | 153 | edit_position = %Position{line: 5, character: 0} 154 | 155 | assert %WorkspaceEdit{ 156 | changes: %{ 157 | ^uri => [ 158 | %TextEdit{ 159 | new_text: """ 160 | 161 | defp bar(param1, param2) do 162 | 163 | end 164 | """, 165 | range: %Range{start: ^edit_position, end: ^edit_position} 166 | } 167 | ] 168 | } 169 | } = code_action.edit 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /test/next_ls/extensions/elixir_extension/code_action/unused_variable_test.exs: -------------------------------------------------------------------------------- 1 | defmodule NextLS.ElixirExtension.UnusedVariableTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias GenLSP.Structures.CodeAction 5 | alias GenLSP.Structures.Position 6 | alias GenLSP.Structures.Range 7 | alias GenLSP.Structures.TextEdit 8 | alias GenLSP.Structures.WorkspaceEdit 9 | alias NextLS.ElixirExtension.CodeAction.UnusedVariable 10 | 11 | test "adds an underscore to unused variables" do 12 | text = 13 | String.split( 14 | """ 15 | defmodule Test.Unused do 16 | def hello() do 17 | foo = 3 18 | :world 19 | end 20 | end 21 | """, 22 | "\n" 23 | ) 24 | 25 | start = %Position{character: 4, line: 3} 26 | 27 | diagnostic = %GenLSP.Structures.Diagnostic{ 28 | data: %{"namespace" => "elixir", "type" => "unused_variable"}, 29 | message: "variable \"foo\" is unused (if the variable is not meant to be used, prefix it with an underscore)", 30 | source: "Elixir", 31 | range: %GenLSP.Structures.Range{ 32 | start: start, 33 | end: %{start | character: 999} 34 | } 35 | } 36 | 37 | uri = "file:///home/owner/my_project/hello.ex" 38 | 39 | assert [code_action] = UnusedVariable.new(diagnostic, text, uri) 40 | assert is_struct(code_action, CodeAction) 41 | assert [diagnostic] == code_action.diagnostics 42 | 43 | # We insert a single underscore character at the start position of the unused variable 44 | # Hence the start and end positions are matching the original start position in the diagnostic 45 | assert %WorkspaceEdit{ 46 | changes: %{ 47 | ^uri => [ 48 | %TextEdit{ 49 | new_text: "_", 50 | range: %Range{start: ^start, end: ^start} 51 | } 52 | ] 53 | } 54 | } = code_action.edit 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/next_ls/extensions/elixir_extension/code_action_test.exs: -------------------------------------------------------------------------------- 1 | defmodule NextLS.Extensions.ElixirExtension.CodeActionTest do 2 | use ExUnit.Case, async: true 3 | 4 | import GenLSP.Test 5 | import NextLS.Support.Utils 6 | 7 | @moduletag :tmp_dir 8 | @moduletag root_paths: ["my_proj"] 9 | 10 | setup %{tmp_dir: tmp_dir} do 11 | File.mkdir_p!(Path.join(tmp_dir, "my_proj/lib")) 12 | File.write!(Path.join(tmp_dir, "my_proj/mix.exs"), mix_exs()) 13 | 14 | cwd = Path.join(tmp_dir, "my_proj") 15 | 16 | foo_path = Path.join(cwd, "lib/foo.ex") 17 | bar_path = Path.join(cwd, "lib/bar.ex") 18 | 19 | foo = """ 20 | defmodule MyProj.Foo do 21 | def hello() do 22 | foo = :bar 23 | :world 24 | end 25 | 26 | def world() do 27 | Logger.info("no require") 28 | end 29 | end 30 | """ 31 | 32 | bar = """ 33 | defmodule MyProj.Bar do 34 | def foo() do 35 | a = :bar 36 | foo(dbg(a), IO.inspect(a)) 37 | a 38 | end 39 | end 40 | """ 41 | 42 | File.write!(foo_path, foo) 43 | File.write!(bar_path, bar) 44 | 45 | [foo: foo, foo_path: foo_path, bar: bar, bar_path: bar_path] 46 | end 47 | 48 | setup :with_lsp 49 | 50 | setup context do 51 | assert :ok == notify(context.client, %{method: "initialized", jsonrpc: "2.0", params: %{}}) 52 | assert_is_ready(context, "my_proj") 53 | assert_compiled(context, "my_proj") 54 | assert_notification "$/progress", %{"value" => %{"kind" => "end", "message" => "Finished indexing!"}} 55 | 56 | did_open(context.client, context.foo_path, context.foo) 57 | did_open(context.client, context.bar_path, context.bar) 58 | context 59 | end 60 | 61 | test "sends back a list of code actions", %{client: client, foo_path: foo} do 62 | foo_uri = uri(foo) 63 | id = 1 64 | 65 | request client, %{ 66 | method: "textDocument/codeAction", 67 | id: id, 68 | jsonrpc: "2.0", 69 | params: %{ 70 | context: %{ 71 | "diagnostics" => [ 72 | %{ 73 | "data" => %{"namespace" => "elixir", "type" => "unused_variable"}, 74 | "message" => 75 | "variable \"foo\" is unused (if the variable is not meant to be used, prefix it with an underscore)", 76 | "range" => %{"end" => %{"character" => 999, "line" => 3}, "start" => %{"character" => 4, "line" => 3}}, 77 | "severity" => 2, 78 | "source" => "Elixir" 79 | } 80 | ] 81 | }, 82 | range: %{start: %{line: 2, character: 4}, end: %{line: 2, character: 999}}, 83 | textDocument: %{uri: foo_uri} 84 | } 85 | } 86 | 87 | assert_receive %{ 88 | "jsonrpc" => "2.0", 89 | "id" => 1, 90 | "result" => [%{"edit" => %{"changes" => %{^foo_uri => [%{"newText" => "_"}]}}}] 91 | }, 92 | 500 93 | end 94 | 95 | test "can send more than one code action", %{client: client, foo_path: foo} do 96 | foo_uri = uri(foo) 97 | id = 1 98 | 99 | request client, %{ 100 | method: "textDocument/codeAction", 101 | id: id, 102 | jsonrpc: "2.0", 103 | params: %{ 104 | context: %{ 105 | "diagnostics" => [ 106 | %{ 107 | "data" => %{"namespace" => "elixir", "type" => "unused_variable"}, 108 | "message" => 109 | "variable \"foo\" is unused (if the variable is not meant to be used, prefix it with an underscore)", 110 | "range" => %{"end" => %{"character" => 999, "line" => 2}, "start" => %{"character" => 4, "line" => 2}}, 111 | "severity" => 2, 112 | "source" => "Elixir" 113 | }, 114 | %{ 115 | "data" => %{"namespace" => "elixir", "type" => "require"}, 116 | "message" => "you must require Logger before invoking the macro Logger.info/1", 117 | "range" => %{"end" => %{"character" => 999, "line" => 7}, "start" => %{"character" => 0, "line" => 7}}, 118 | "severity" => 2, 119 | "source" => "Elixir" 120 | } 121 | ] 122 | }, 123 | range: %{start: %{line: 2, character: 0}, end: %{line: 7, character: 999}}, 124 | textDocument: %{uri: foo_uri} 125 | } 126 | } 127 | 128 | assert_receive %{ 129 | "jsonrpc" => "2.0", 130 | "id" => 1, 131 | "result" => [ 132 | %{"edit" => %{"changes" => %{^foo_uri => [%{"newText" => "_"}]}}}, 133 | %{"edit" => %{"changes" => %{^foo_uri => [%{"newText" => " require Logger\n"}]}}} 134 | ] 135 | }, 136 | 500 137 | end 138 | 139 | test "sends back a remove inspect action", %{client: client, bar_path: bar} do 140 | bar_uri = uri(bar) 141 | id = 1 142 | 143 | request client, %{ 144 | method: "textDocument/codeAction", 145 | id: id, 146 | jsonrpc: "2.0", 147 | params: %{ 148 | context: %{ 149 | "diagnostics" => [ 150 | %{ 151 | "data" => %{"namespace" => "credo", "check" => "Elixir.Credo.Check.Warning.Dbg"}, 152 | "message" => "There should be no calls to `dbg/1`.", 153 | "range" => %{"end" => %{"character" => 13, "line" => 3}, "start" => %{"character" => 8, "line" => 3}}, 154 | "severity" => 2, 155 | "source" => "Elixir" 156 | }, 157 | %{ 158 | "data" => %{"namespace" => "credo", "check" => "Elixir.Credo.Check.Warning.IoInspect"}, 159 | "message" => "There should be no calls to `IO.inspect/1`.", 160 | "range" => %{"end" => %{"character" => 28, "line" => 3}, "start" => %{"character" => 20, "line" => 3}}, 161 | "severity" => 2, 162 | "source" => "Elixir" 163 | } 164 | ] 165 | }, 166 | range: %{start: %{line: 0, character: 0}, end: %{line: 7, character: 999}}, 167 | textDocument: %{uri: bar_uri} 168 | } 169 | } 170 | 171 | assert_receive %{ 172 | "jsonrpc" => "2.0", 173 | "id" => 1, 174 | "result" => [ 175 | %{"edit" => %{"changes" => %{^bar_uri => [%{"newText" => "a", "range" => range1}]}}}, 176 | %{"edit" => %{"changes" => %{^bar_uri => [%{"newText" => "a", "range" => range2}]}}} 177 | ] 178 | }, 179 | 500 180 | 181 | assert %{"start" => %{"character" => 8, "line" => 3}, "end" => %{"character" => 14, "line" => 3}} == range1 182 | assert %{"start" => %{"character" => 16, "line" => 3}, "end" => %{"character" => 29, "line" => 3}} == range2 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /test/next_ls/extensions/elixir_extension_test.exs: -------------------------------------------------------------------------------- 1 | defmodule NextLS.ElixirExtensionTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias GenLSP.Structures.Position 5 | alias NextLS.DiagnosticCache 6 | alias NextLS.ElixirExtension 7 | 8 | setup do 9 | cache = start_supervised!(DiagnosticCache) 10 | start_supervised!({Registry, [keys: :unique, name: Registry.ElixirExtensionTest]}) 11 | 12 | extension = 13 | start_supervised!({ElixirExtension, cache: cache, registry: Registry.ElixirExtensionTest, publisher: self()}) 14 | 15 | Process.link(extension) 16 | 17 | [extension: extension, cache: cache] 18 | end 19 | 20 | test "inserts lsp diagnostics into cache", %{extension: extension, cache: cache} do 21 | only_line = %Mix.Task.Compiler.Diagnostic{ 22 | file: "lib/bar.ex", 23 | severity: :warning, 24 | message: "kind of bad", 25 | position: 2, 26 | compiler_name: "Elixir", 27 | details: nil 28 | } 29 | 30 | line_and_col = %Mix.Task.Compiler.Diagnostic{ 31 | file: "lib/foo.ex", 32 | severity: :error, 33 | message: "nothing works", 34 | position: {4, 7}, 35 | compiler_name: "Elixir", 36 | details: nil 37 | } 38 | 39 | with_iodata = %Mix.Task.Compiler.Diagnostic{ 40 | file: "/Users/mitchell/src/next_ls/lib/next_ls/runtime.ex", 41 | severity: :warning, 42 | message: [ 43 | "ElixirExtension.foo/0", 44 | " is undefined (module ", 45 | "ElixirExtension", 46 | " is not available or is yet to be defined)" 47 | ], 48 | position: 29, 49 | compiler_name: "Elixir", 50 | details: nil 51 | } 52 | 53 | start_and_end = %Mix.Task.Compiler.Diagnostic{ 54 | file: "lib/baz.ex", 55 | severity: :hint, 56 | message: "here's a hint", 57 | position: {4, 7, 8, 3}, 58 | compiler_name: "Elixir", 59 | details: nil 60 | } 61 | 62 | send(extension, {:compiler, [only_line, line_and_col, start_and_end, with_iodata]}) 63 | 64 | assert_receive :publish 65 | 66 | assert %{ 67 | with_iodata.file => [ 68 | %GenLSP.Structures.Diagnostic{ 69 | data: %{"namespace" => "elixir"}, 70 | severity: 2, 71 | message: 72 | "ElixirExtension.foo/0" <> 73 | " is undefined (module " <> "ElixirExtension" <> " is not available or is yet to be defined)", 74 | source: "Elixir", 75 | range: %GenLSP.Structures.Range{ 76 | start: %Position{ 77 | line: 28, 78 | character: 0 79 | }, 80 | end: %Position{ 81 | line: 28, 82 | character: 999 83 | } 84 | } 85 | } 86 | ], 87 | only_line.file => [ 88 | %GenLSP.Structures.Diagnostic{ 89 | data: %{"namespace" => "elixir"}, 90 | severity: 2, 91 | message: "kind of bad", 92 | source: "Elixir", 93 | range: %GenLSP.Structures.Range{ 94 | start: %Position{ 95 | line: 1, 96 | character: 0 97 | }, 98 | end: %Position{ 99 | line: 1, 100 | character: 999 101 | } 102 | } 103 | } 104 | ], 105 | line_and_col.file => [ 106 | %GenLSP.Structures.Diagnostic{ 107 | data: %{"namespace" => "elixir"}, 108 | severity: 1, 109 | message: "nothing works", 110 | source: "Elixir", 111 | range: %GenLSP.Structures.Range{ 112 | start: %Position{ 113 | line: 3, 114 | character: 6 115 | }, 116 | end: %Position{ 117 | line: 3, 118 | character: 999 119 | } 120 | } 121 | } 122 | ], 123 | start_and_end.file => [ 124 | %GenLSP.Structures.Diagnostic{ 125 | data: %{"namespace" => "elixir"}, 126 | severity: 4, 127 | message: "here's a hint", 128 | source: "Elixir", 129 | range: %GenLSP.Structures.Range{ 130 | start: %Position{ 131 | line: 3, 132 | character: 6 133 | }, 134 | end: %Position{ 135 | line: 7, 136 | character: 2 137 | } 138 | } 139 | } 140 | ] 141 | } == DiagnosticCache.get(cache).elixir 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /test/next_ls/helpers/ast_helpers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule NextLS.ASTHelpersTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias GenLSP.Structures.Position 5 | alias NextLS.ASTHelpers 6 | alias NextLS.ASTHelpers.Aliases 7 | 8 | describe "inside?/2" do 9 | # example full snippet is the outer 10 | # alias One.Two.{ 11 | # Three, 12 | # Four # this is the target target 13 | # ~~~~ 14 | # } 15 | # alias One.Four 16 | test "completely inside outer range" do 17 | outer = {{1, 1}, {4, 1}} 18 | target = {{3, 3}, {3, 6}} 19 | 20 | assert ASTHelpers.inside?(outer, target) 21 | end 22 | 23 | test "completely outside outer range" do 24 | outer = {{1, 1}, {4, 1}} 25 | target = {{5, 11}, {5, 14}} 26 | 27 | refute ASTHelpers.inside?(outer, target) 28 | end 29 | end 30 | 31 | describe "extract_aliases" do 32 | test "extracts a normal alias" do 33 | code = """ 34 | defmodule Foo do 35 | alias One.Two.Three 36 | end 37 | """ 38 | 39 | start = %{line: 2, col: 3} 40 | stop = %{line: 2, col: 21} 41 | ale = :Three 42 | 43 | assert {{2, 9}, {2, 21}} == Aliases.extract_alias_range(code, {start, stop}, ale) 44 | end 45 | 46 | test "extract an inline multi alias" do 47 | code = """ 48 | defmodule Foo do 49 | alias One.Two.{Three, Four} 50 | end 51 | """ 52 | 53 | start = %{line: 2, col: 3} 54 | stop = %{line: 2, col: 29} 55 | 56 | assert {{2, 18}, {2, 22}} == Aliases.extract_alias_range(code, {start, stop}, :Three) 57 | assert {{2, 25}, {2, 28}} == Aliases.extract_alias_range(code, {start, stop}, :Four) 58 | end 59 | 60 | test "extract a multi line, multi alias" do 61 | code = """ 62 | defmodule Foo do 63 | alias One.Four 64 | alias One.Two.{ 65 | Three, 66 | Four 67 | } 68 | end 69 | """ 70 | 71 | start = %{line: 3, col: 3} 72 | stop = %{line: 6, col: 3} 73 | 74 | assert {{4, 5}, {4, 9}} == Aliases.extract_alias_range(code, {start, stop}, :Three) 75 | assert {{5, 5}, {5, 8}} == Aliases.extract_alias_range(code, {start, stop}, :Four) 76 | end 77 | end 78 | 79 | describe "get_surrounding_module/2" do 80 | test "finds the nearest defmodule definition in the ast" do 81 | {:ok, ast} = 82 | Spitfire.parse(""" 83 | defmodule Test do 84 | defmodule Foo do 85 | def hello(), do: :foo 86 | end 87 | 88 | defmodule Bar do 89 | def hello(), do: :bar 90 | end 91 | end 92 | """) 93 | 94 | for {line, character} <- [{0, 2}, {1, 1}, {4, 0}, {5, 1}, {8, 2}] do 95 | position = %Position{line: line, character: character} 96 | 97 | assert {:ok, {:defmodule, _, [{:__aliases__, _, [:Test]} | _]}} = 98 | ASTHelpers.get_surrounding_module(ast, position) 99 | end 100 | 101 | for {line, character} <- [{1, 2}, {1, 6}, {2, 5}, {3, 3}] do 102 | position = %Position{line: line, character: character} 103 | 104 | assert {:ok, {:defmodule, _, [{:__aliases__, _, [:Foo]} | _]}} = 105 | ASTHelpers.get_surrounding_module(ast, position) 106 | end 107 | 108 | for {line, character} <- [{5, 4}, {6, 1}, {7, 0}, {7, 3}] do 109 | position = %Position{line: line, character: character} 110 | 111 | assert {:ok, {:defmodule, _, [{:__aliases__, _, [:Bar]} | _]}} = 112 | ASTHelpers.get_surrounding_module(ast, position) 113 | end 114 | end 115 | 116 | test "errors out when it can't find a module" do 117 | {:ok, ast} = 118 | Spitfire.parse(""" 119 | def foo, do: :bar 120 | """) 121 | 122 | position = %Position{line: 0, character: 0} 123 | assert {:error, :not_found} = ASTHelpers.get_surrounding_module(ast, position) 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /test/next_ls/pipe_test.exs: -------------------------------------------------------------------------------- 1 | defmodule NextLS.PipeTest do 2 | use ExUnit.Case, async: true 3 | 4 | import GenLSP.Test 5 | import NextLS.Support.Utils 6 | 7 | @moduletag :tmp_dir 8 | @moduletag root_paths: ["my_proj"] 9 | 10 | setup %{tmp_dir: tmp_dir} do 11 | File.mkdir_p!(Path.join(tmp_dir, "my_proj/lib")) 12 | File.write!(Path.join(tmp_dir, "my_proj/mix.exs"), mix_exs()) 13 | 14 | cwd = Path.join(tmp_dir, "my_proj") 15 | 16 | foo_path = Path.join(cwd, "lib/foo.ex") 17 | 18 | foo = """ 19 | defmodule Foo do 20 | def to_list() do 21 | Enum.to_list(Map.new()) 22 | end 23 | end 24 | """ 25 | 26 | File.write!(foo_path, foo) 27 | 28 | bar_path = Path.join(cwd, "lib/bar.ex") 29 | 30 | bar = """ 31 | defmodule Bar do 32 | def to_list() do 33 | Map.new() |> Enum.to_list() 34 | end 35 | end 36 | """ 37 | 38 | File.write!(bar_path, bar) 39 | 40 | [foo: foo, foo_path: foo_path, bar: bar, bar_path: bar_path] 41 | end 42 | 43 | setup :with_lsp 44 | 45 | setup context do 46 | assert :ok == notify(context.client, %{method: "initialized", jsonrpc: "2.0", params: %{}}) 47 | assert_is_ready(context, "my_proj") 48 | assert_compiled(context, "my_proj") 49 | assert_notification "$/progress", %{"value" => %{"kind" => "end", "message" => "Finished indexing!"}} 50 | 51 | did_open(context.client, context.foo_path, context.foo) 52 | did_open(context.client, context.bar_path, context.bar) 53 | context 54 | end 55 | 56 | test "transforms nested function expressions to pipes", %{client: client, foo_path: foo} do 57 | foo_uri = uri(foo) 58 | id = 1 59 | 60 | request client, %{ 61 | method: "workspace/executeCommand", 62 | id: id, 63 | jsonrpc: "2.0", 64 | params: %{ 65 | command: "to-pipe", 66 | arguments: [%{uri: foo_uri, position: %{line: 2, character: 19}}] 67 | } 68 | } 69 | 70 | assert_request(client, "workspace/applyEdit", 500, fn params -> 71 | assert %{"edit" => edit, "label" => "Extracted to a pipe"} = params 72 | 73 | assert %{ 74 | "changes" => %{ 75 | ^foo_uri => [%{"newText" => text, "range" => range}] 76 | } 77 | } = edit 78 | 79 | expected = "Map.new() |> Enum.to_list()" 80 | assert text == expected 81 | assert range["start"] == %{"character" => 4, "line" => 2} 82 | assert range["end"] == %{"character" => 27, "line" => 2} 83 | end) 84 | end 85 | 86 | test "transforms pipes to function expressions", %{client: client, bar_path: bar} do 87 | bar_uri = uri(bar) 88 | id = 2 89 | 90 | request client, %{ 91 | method: "workspace/executeCommand", 92 | id: id, 93 | jsonrpc: "2.0", 94 | params: %{ 95 | command: "from-pipe", 96 | arguments: [%{uri: bar_uri, position: %{line: 2, character: 9}}] 97 | } 98 | } 99 | 100 | assert_request(client, "workspace/applyEdit", 500, fn params -> 101 | assert %{"edit" => edit, "label" => "Inlined pipe"} = params 102 | 103 | assert %{ 104 | "changes" => %{ 105 | ^bar_uri => [%{"newText" => text, "range" => range}] 106 | } 107 | } = edit 108 | 109 | expected = "Enum.to_list(Map.new())" 110 | assert text == expected 111 | assert range["start"] == %{"character" => 4, "line" => 2} 112 | assert range["end"] == %{"character" => 31, "line" => 2} 113 | end) 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /test/next_ls/references_test.exs: -------------------------------------------------------------------------------- 1 | defmodule NextLS.ReferencesTest do 2 | use ExUnit.Case, async: true 3 | 4 | import GenLSP.Test 5 | import NextLS.Support.Utils 6 | 7 | @moduletag :tmp_dir 8 | @moduletag root_paths: ["my_proj"] 9 | setup %{tmp_dir: tmp_dir} do 10 | File.mkdir_p!(Path.join(tmp_dir, "my_proj/lib")) 11 | File.write!(Path.join(tmp_dir, "my_proj/mix.exs"), mix_exs()) 12 | 13 | [cwd: tmp_dir] 14 | end 15 | 16 | setup %{cwd: cwd} do 17 | peace = Path.join(cwd, "my_proj/lib/peace.ex") 18 | 19 | File.write!(peace, """ 20 | defmodule MyApp.Peace do 21 | def and_love() do 22 | "✌️" 23 | end 24 | end 25 | """) 26 | 27 | bar = Path.join(cwd, "my_proj/lib/bar.ex") 28 | 29 | File.write!(bar, """ 30 | defmodule Bar do 31 | alias MyApp.Peace 32 | def run() do 33 | Peace.and_love() 34 | end 35 | end 36 | 37 | defmodule Foo do 38 | @foo_attr 123 39 | 40 | def foo_foo(a) do 41 | {:ok, a + @foo_attr} 42 | end 43 | 44 | def foo2 do 45 | alpha = 1 46 | bravo = 2 47 | charlie = alpha + bravo 48 | delta = alpha 49 | 50 | alpha = false 51 | {:error, @foo_attr, alpha} 52 | end 53 | end 54 | """) 55 | 56 | [bar: bar, peace: peace] 57 | end 58 | 59 | setup :with_lsp 60 | 61 | test "list function references", %{client: client, bar: bar, peace: peace} = context do 62 | assert :ok == notify(client, %{method: "initialized", jsonrpc: "2.0", params: %{}}) 63 | 64 | assert_is_ready(context, "my_proj") 65 | assert_compiled(context, "my_proj") 66 | assert_notification "$/progress", %{"value" => %{"kind" => "end", "message" => "Finished indexing!"}} 67 | 68 | request(client, %{ 69 | method: "textDocument/references", 70 | id: 4, 71 | jsonrpc: "2.0", 72 | params: %{ 73 | position: %{line: 1, character: 6}, 74 | textDocument: %{uri: uri(peace)}, 75 | context: %{includeDeclaration: true} 76 | } 77 | }) 78 | 79 | uri = uri(bar) 80 | 81 | assert_result2( 82 | 4, 83 | [ 84 | %{ 85 | "uri" => uri, 86 | "range" => %{ 87 | "start" => %{"line" => 3, "character" => 10}, 88 | "end" => %{"line" => 3, "character" => 17} 89 | } 90 | } 91 | ] 92 | ) 93 | end 94 | 95 | test "list module references", %{client: client, bar: bar, peace: peace} = context do 96 | assert :ok == notify(client, %{method: "initialized", jsonrpc: "2.0", params: %{}}) 97 | 98 | assert_is_ready(context, "my_proj") 99 | assert_compiled(context, "my_proj") 100 | assert_notification "$/progress", %{"value" => %{"kind" => "end", "message" => "Finished indexing!"}} 101 | 102 | request(client, %{ 103 | method: "textDocument/references", 104 | id: 4, 105 | jsonrpc: "2.0", 106 | params: %{ 107 | position: %{line: 0, character: 10}, 108 | textDocument: %{uri: uri(peace)}, 109 | context: %{includeDeclaration: true} 110 | } 111 | }) 112 | 113 | uri = uri(bar) 114 | 115 | assert_result 4, 116 | [ 117 | %{ 118 | "uri" => ^uri, 119 | "range" => %{ 120 | "start" => %{"line" => 1, "character" => 8}, 121 | "end" => %{"line" => 1, "character" => 18} 122 | } 123 | }, 124 | %{ 125 | "uri" => ^uri, 126 | "range" => %{ 127 | "start" => %{"line" => 3, "character" => 4}, 128 | "end" => %{"line" => 3, "character" => 8} 129 | } 130 | } 131 | ] 132 | end 133 | 134 | test "list attribute references", %{client: client, bar: bar} = context do 135 | assert :ok == notify(client, %{method: "initialized", jsonrpc: "2.0", params: %{}}) 136 | 137 | assert_is_ready(context, "my_proj") 138 | assert_compiled(context, "my_proj") 139 | assert_notification "$/progress", %{"value" => %{"kind" => "end", "message" => "Finished indexing!"}} 140 | 141 | request(client, %{ 142 | method: "textDocument/references", 143 | id: 4, 144 | jsonrpc: "2.0", 145 | params: %{ 146 | position: %{line: 8, character: 4}, 147 | textDocument: %{uri: uri(bar)}, 148 | context: %{includeDeclaration: true} 149 | } 150 | }) 151 | 152 | uri = uri(bar) 153 | 154 | assert_result2( 155 | 4, 156 | [ 157 | %{ 158 | "uri" => uri, 159 | "range" => %{ 160 | "start" => %{"line" => 11, "character" => 14}, 161 | "end" => %{"line" => 11, "character" => 22} 162 | } 163 | }, 164 | %{ 165 | "uri" => uri, 166 | "range" => %{ 167 | "start" => %{"line" => 21, "character" => 13}, 168 | "end" => %{"line" => 21, "character" => 21} 169 | } 170 | } 171 | ] 172 | ) 173 | end 174 | 175 | test "list variable references", %{client: client, bar: bar} = context do 176 | assert :ok == notify(client, %{method: "initialized", jsonrpc: "2.0", params: %{}}) 177 | 178 | assert_is_ready(context, "my_proj") 179 | assert_compiled(context, "my_proj") 180 | assert_notification "$/progress", %{"value" => %{"kind" => "end", "message" => "Finished indexing!"}} 181 | 182 | request(client, %{ 183 | method: "textDocument/references", 184 | id: 4, 185 | jsonrpc: "2.0", 186 | params: %{ 187 | position: %{line: 15, character: 5}, 188 | textDocument: %{uri: uri(bar)}, 189 | context: %{includeDeclaration: true} 190 | } 191 | }) 192 | 193 | uri = uri(bar) 194 | 195 | assert_result2( 196 | 4, 197 | [ 198 | %{ 199 | "uri" => uri, 200 | "range" => %{ 201 | "start" => %{"line" => 17, "character" => 14}, 202 | "end" => %{"line" => 17, "character" => 18} 203 | } 204 | }, 205 | %{ 206 | "uri" => uri, 207 | "range" => %{ 208 | "start" => %{"line" => 18, "character" => 12}, 209 | "end" => %{"line" => 18, "character" => 16} 210 | } 211 | } 212 | ] 213 | ) 214 | end 215 | end 216 | -------------------------------------------------------------------------------- /test/next_ls/runtime_test.exs: -------------------------------------------------------------------------------- 1 | defmodule NextLs.RuntimeTest do 2 | use ExUnit.Case, async: true 3 | 4 | import NextLS.Support.Utils 5 | 6 | alias NextLS.Runtime 7 | 8 | require Logger 9 | 10 | @moduletag :tmp_dir 11 | 12 | setup %{tmp_dir: tmp_dir} do 13 | File.write!(Path.join(tmp_dir, "mix.exs"), mix_exs()) 14 | File.mkdir_p!(Path.join(tmp_dir, "lib")) 15 | 16 | File.write!(Path.join(tmp_dir, "lib/bar.ex"), """ 17 | defmodule Bar do 18 | defstruct [:foo] 19 | 20 | def foo(arg1) do 21 | end 22 | end 23 | """) 24 | 25 | me = self() 26 | 27 | {:ok, logger} = 28 | Task.start_link(fn -> 29 | recv = fn recv -> 30 | receive do 31 | {:"$gen_cast", msg} -> send(me, msg) 32 | end 33 | 34 | recv.(recv) 35 | end 36 | 37 | recv.(recv) 38 | end) 39 | 40 | on_init = fn msg -> send(me, msg) end 41 | 42 | on_exit(&flush_messages/0) 43 | 44 | [logger: logger, cwd: Path.absname(tmp_dir), on_init: on_init] 45 | end 46 | 47 | describe "call/2" do 48 | test "responds with an ok tuple if the runtime has initialized", 49 | %{logger: logger, cwd: cwd, on_init: on_init} do 50 | start_supervised!({Registry, keys: :duplicate, name: RuntimeTest.Registry}) 51 | tvisor = start_supervised!(Task.Supervisor) 52 | 53 | pid = 54 | start_supervised!( 55 | {Runtime, 56 | name: "my_proj", 57 | on_initialized: on_init, 58 | task_supervisor: tvisor, 59 | working_dir: cwd, 60 | uri: "file://#{cwd}", 61 | elixir_bin_path: "elixir" |> System.find_executable() |> Path.dirname(), 62 | parent: self(), 63 | lsp_pid: self(), 64 | logger: logger, 65 | db: :some_db, 66 | mix_env: "dev", 67 | mix_target: "host", 68 | mix_home: Path.join(cwd, ".mix"), 69 | mix_archives: Path.join(cwd, [".mix", "archives"]), 70 | registry: RuntimeTest.Registry} 71 | ) 72 | 73 | Process.link(pid) 74 | 75 | assert_receive :ready 76 | 77 | assert {:ok, "\"hi\""} = Runtime.call(pid, {Kernel, :inspect, ["hi"]}) 78 | end 79 | 80 | test "responds with an error when the runtime hasn't initialized", %{logger: logger, cwd: cwd, on_init: on_init} do 81 | start_supervised!({Registry, keys: :duplicate, name: RuntimeTest.Registry}) 82 | 83 | tvisor = start_supervised!(Task.Supervisor) 84 | 85 | pid = 86 | start_supervised!( 87 | {Runtime, 88 | task_supervisor: tvisor, 89 | name: "my_proj", 90 | on_initialized: on_init, 91 | working_dir: cwd, 92 | uri: "file://#{cwd}", 93 | elixir_bin_path: "elixir" |> System.find_executable() |> Path.dirname(), 94 | parent: self(), 95 | lsp_pid: self(), 96 | logger: logger, 97 | db: :some_db, 98 | mix_env: "dev", 99 | mix_target: "host", 100 | mix_home: Path.join(cwd, ".mix"), 101 | mix_archives: Path.join(cwd, [".mix", "archives"]), 102 | registry: RuntimeTest.Registry} 103 | ) 104 | 105 | Process.link(pid) 106 | 107 | assert {:error, :not_ready} = Runtime.call(pid, {IO, :puts, ["hi"]}) 108 | end 109 | end 110 | 111 | describe "compile/1" do 112 | test "compiles the project and returns diagnostics", 113 | %{logger: logger, cwd: cwd, on_init: on_init} do 114 | start_supervised!({Registry, keys: :duplicate, name: RuntimeTest.Registry}) 115 | 116 | tvisor = start_supervised!(Task.Supervisor) 117 | 118 | pid = 119 | start_link_supervised!( 120 | {Runtime, 121 | name: "my_proj", 122 | on_initialized: on_init, 123 | task_supervisor: tvisor, 124 | working_dir: cwd, 125 | uri: "file://#{cwd}", 126 | elixir_bin_path: "elixir" |> System.find_executable() |> Path.dirname(), 127 | parent: self(), 128 | lsp_pid: self(), 129 | logger: logger, 130 | db: :some_db, 131 | mix_env: "dev", 132 | mix_target: "host", 133 | mix_home: Path.join(cwd, ".mix"), 134 | mix_archives: Path.join(cwd, [".mix", "archives"]), 135 | registry: RuntimeTest.Registry} 136 | ) 137 | 138 | assert_receive :ready, 5000 139 | 140 | file = Path.join(cwd, "lib/bar.ex") 141 | 142 | ref = make_ref() 143 | assert :ok == Runtime.compile(pid, caller_ref: ref) 144 | 145 | assert_receive {:compiler_result, ^ref, "my_proj", 146 | {:ok, 147 | [ 148 | %Mix.Task.Compiler.Diagnostic{ 149 | file: ^file, 150 | severity: :warning, 151 | message: 152 | "variable \"arg1\" is unused (if the variable is not meant to be used, prefix it with an underscore)", 153 | position: position, 154 | compiler_name: "Elixir", 155 | details: nil 156 | } 157 | ]}} 158 | 159 | if Version.match?(System.version(), ">= 1.15.0") do 160 | assert position == {4, 11} 161 | else 162 | assert position == 4 163 | end 164 | 165 | File.write!(file, """ 166 | defmodule Bar do 167 | def foo(arg1) do 168 | arg1 169 | end 170 | end 171 | """) 172 | 173 | ref = make_ref() 174 | assert :ok == Runtime.compile(pid, caller_ref: ref) 175 | 176 | assert_receive {:compiler_result, ^ref, "my_proj", {:ok, []}} 177 | end 178 | 179 | test "responds with an error when the runtime isn't ready", %{logger: logger, cwd: cwd, on_init: on_init} do 180 | start_supervised!({Registry, keys: :duplicate, name: RuntimeTest.Registry}) 181 | 182 | tvisor = start_supervised!(Task.Supervisor) 183 | 184 | pid = 185 | start_supervised!( 186 | {Runtime, 187 | task_supervisor: tvisor, 188 | name: "my_proj", 189 | on_initialized: on_init, 190 | working_dir: cwd, 191 | uri: "file://#{cwd}", 192 | elixir_bin_path: "elixir" |> System.find_executable() |> Path.dirname(), 193 | parent: self(), 194 | lsp_pid: self(), 195 | logger: logger, 196 | db: :some_db, 197 | mix_env: "dev", 198 | mix_target: "host", 199 | mix_home: Path.join(cwd, ".mix"), 200 | mix_archives: Path.join(cwd, [".mix", "archives"]), 201 | registry: RuntimeTest.Registry} 202 | ) 203 | 204 | Process.link(pid) 205 | 206 | assert {:error, :not_ready} = Runtime.compile(pid) 207 | end 208 | end 209 | 210 | defp flush_messages do 211 | receive do 212 | _ -> flush_messages() 213 | after 214 | 0 -> :ok 215 | end 216 | end 217 | end 218 | -------------------------------------------------------------------------------- /test/next_ls/snippet_test.exs: -------------------------------------------------------------------------------- 1 | defmodule NextLS.SnippetTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias NextLS.Snippet 5 | 6 | describe "defmodule snippet" do 7 | test "simple module" do 8 | assert %{insert_text: "defmodule ${1:Foo} do\n $0\nend\n", insert_text_format: 2, kind: 15} == 9 | Snippet.get("defmodule/2", nil, uri: "lib/foo.ex") 10 | end 11 | 12 | test "nested module" do 13 | assert %{insert_text: "defmodule ${1:Foo.Bar.Baz} do\n $0\nend\n", insert_text_format: 2, kind: 15} == 14 | Snippet.get("defmodule/2", nil, uri: "lib/foo/bar/baz.ex") 15 | end 16 | 17 | test "test module" do 18 | assert %{insert_text: "defmodule ${1:FooTest} do\n $0\nend\n", insert_text_format: 2, kind: 15} == 19 | Snippet.get("defmodule/2", nil, uri: "test/foo_test.exs") 20 | end 21 | 22 | test "support test module" do 23 | assert %{insert_text: "defmodule ${1:Foo} do\n $0\nend\n", insert_text_format: 2, kind: 15} == 24 | Snippet.get("defmodule/2", nil, uri: "test/support/foo.ex") 25 | end 26 | 27 | test "module outside canonical folders" do 28 | assert %{insert_text: "defmodule ${1:Foo} do\n $0\nend\n", insert_text_format: 2, kind: 15} == 29 | Snippet.get("defmodule/2", nil, uri: "foo.ex") 30 | end 31 | 32 | test "without uri" do 33 | assert %{insert_text: "defmodule ${1:ModuleName} do\n $0\nend\n", insert_text_format: 2, kind: 15} == 34 | Snippet.get("defmodule/2", nil) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/next_ls/updater_test.exs: -------------------------------------------------------------------------------- 1 | defmodule NextLS.UpdaterTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias NextLS.Updater 5 | 6 | @moduletag :tmp_dir 7 | 8 | setup do 9 | me = self() 10 | 11 | {:ok, logger} = 12 | Task.start_link(fn -> 13 | recv = fn recv -> 14 | receive do 15 | {:"$gen_cast", msg} -> 16 | # dbg(msg) 17 | send(me, msg) 18 | end 19 | 20 | recv.(recv) 21 | end 22 | 23 | recv.(recv) 24 | end) 25 | 26 | [logger: logger] 27 | end 28 | 29 | test "downloads the exe", %{tmp_dir: tmp_dir, logger: logger} do 30 | api = Bypass.open(port: 8000) 31 | github = Bypass.open(port: 8001) 32 | 33 | Bypass.expect(api, "GET", "/repos/elixir-tools/next-ls/releases/latest", fn conn -> 34 | conn 35 | |> Plug.Conn.put_resp_header("content-type", "application/json") 36 | |> Plug.Conn.resp(200, Jason.encode!(%{tag_name: "v1.0.0"})) 37 | end) 38 | 39 | exe = String.duplicate("time to hack\n", 1000) 40 | 41 | Bypass.expect(github, fn conn -> 42 | assert "GET" == conn.method 43 | assert "/elixir-tools/next-ls/releases/download/v1.0.0/next_ls_" <> rest = conn.request_path 44 | 45 | assert rest in [ 46 | "darwin_arm64", 47 | "darwin_amd64", 48 | "linux_arm64", 49 | "linux_amd64", 50 | "windows_amd64" 51 | ] 52 | 53 | Plug.Conn.resp(conn, 200, exe) 54 | end) 55 | 56 | binpath = Path.join(tmp_dir, "nextls") 57 | File.write(binpath, "yoyoyo") 58 | 59 | Updater.run( 60 | current_version: Version.parse!("0.9.0"), 61 | binpath: binpath, 62 | api_host: "http://localhost:8000", 63 | github_host: "http://localhost:8001", 64 | logger: logger 65 | ) 66 | 67 | assert File.read!(binpath) == exe 68 | assert File.stat!(binpath).mode == 33_261 69 | assert File.stat!(binpath).size > 10_000 70 | assert File.exists?(binpath <> "-0.9.0") 71 | end 72 | 73 | test "doesn't download when the version is at the latest", %{tmp_dir: tmp_dir, logger: logger} do 74 | api = Bypass.open(port: 8000) 75 | 76 | Bypass.expect(api, "GET", "/repos/elixir-tools/next-ls/releases/latest", fn conn -> 77 | conn 78 | |> Plug.Conn.put_resp_header("content-type", "application/json") 79 | |> Plug.Conn.resp(200, Jason.encode!(%{tag_name: "v1.0.0"})) 80 | end) 81 | 82 | binpath = Path.join(tmp_dir, "nextls") 83 | 84 | Updater.run( 85 | current_version: Version.parse!("1.0.0"), 86 | binpath: binpath, 87 | api_host: "http://localhost:8000", 88 | github_host: "http://localhost:8001", 89 | logger: logger 90 | ) 91 | 92 | refute File.exists?(binpath) 93 | end 94 | 95 | test "logs that it failed when api call fails", %{tmp_dir: tmp_dir, logger: logger} do 96 | binpath = Path.join(tmp_dir, "nextls") 97 | File.write(binpath, "yoyoyo") 98 | 99 | Updater.run( 100 | current_version: Version.parse!("1.0.0"), 101 | binpath: binpath, 102 | api_host: "http://localhost:8000", 103 | github_host: "http://localhost:8001", 104 | logger: logger, 105 | retry: false 106 | ) 107 | 108 | assert_receive {:log, :error, "Failed to retrieve the latest version number of Next LS from the GitHub API: " <> _} 109 | end 110 | 111 | test "logs that it failed when download fails", %{tmp_dir: tmp_dir, logger: logger} do 112 | api = Bypass.open(port: 8000) 113 | 114 | Bypass.expect(api, "GET", "/repos/elixir-tools/next-ls/releases/latest", fn conn -> 115 | conn 116 | |> Plug.Conn.put_resp_header("content-type", "application/json") 117 | |> Plug.Conn.resp(200, Jason.encode!(%{tag_name: "v1.0.0"})) 118 | end) 119 | 120 | binpath = Path.join(tmp_dir, "nextls") 121 | File.write(binpath, "yoyoyo") 122 | 123 | Updater.run( 124 | current_version: Version.parse!("0.9.0"), 125 | binpath: binpath, 126 | api_host: "http://localhost:8000", 127 | github_host: "http://localhost:8001", 128 | logger: logger, 129 | retry: false 130 | ) 131 | 132 | assert_receive {:show_message, :error, "Failed to download version 1.0.0 of Next LS!"} 133 | assert_receive {:log, :error, "Failed to download Next LS: " <> _} 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /test/next_ls/workspaces_test.exs: -------------------------------------------------------------------------------- 1 | defmodule NextLS.WorkspacesTest do 2 | use ExUnit.Case, async: true 3 | 4 | import GenLSP.Test 5 | import NextLS.Support.Utils 6 | 7 | @moduletag :tmp_dir 8 | @moduletag root_paths: ["my_proj"] 9 | setup %{tmp_dir: tmp_dir} do 10 | File.mkdir_p!(Path.join(tmp_dir, "my_proj/lib")) 11 | File.write!(Path.join(tmp_dir, "my_proj/mix.exs"), mix_exs()) 12 | 13 | [cwd: tmp_dir] 14 | end 15 | 16 | setup %{cwd: cwd} do 17 | File.mkdir_p!(Path.join(cwd, "proj_one/lib")) 18 | File.write!(Path.join(cwd, "proj_one/mix.exs"), mix_exs()) 19 | peace = Path.join(cwd, "proj_one/lib/peace.ex") 20 | 21 | File.write!(peace, """ 22 | defmodule MyApp.Peace do 23 | def and_love() do 24 | "✌️" 25 | end 26 | end 27 | """) 28 | 29 | File.mkdir_p!(Path.join(cwd, "proj_two/lib")) 30 | File.write!(Path.join(cwd, "proj_two/mix.exs"), mix_exs()) 31 | bar = Path.join(cwd, "proj_two/lib/bar.ex") 32 | 33 | File.write!(bar, """ 34 | defmodule Bar do 35 | def run() do 36 | MyApp.Peace.and_love() 37 | end 38 | end 39 | """) 40 | 41 | [bar: bar, peace: peace] 42 | end 43 | 44 | setup :with_lsp 45 | 46 | @tag root_paths: ["proj_one"] 47 | test "starts a new runtime when you add a workspace folder", %{client: client, cwd: cwd} = context do 48 | assert :ok == notify(client, %{method: "initialized", jsonrpc: "2.0", params: %{}}) 49 | assert_is_ready(context, "proj_one") 50 | assert_compiled(context, "proj_one") 51 | 52 | notify(client, %{ 53 | method: "workspace/didChangeWorkspaceFolders", 54 | jsonrpc: "2.0", 55 | params: %{ 56 | event: %{ 57 | added: [ 58 | %{name: "#{context.module}-proj_two", uri: "file://#{Path.join(cwd, "proj_two")}"} 59 | ], 60 | removed: [] 61 | } 62 | } 63 | }) 64 | 65 | assert_is_ready(context, "proj_two") 66 | assert_compiled(context, "proj_two") 67 | end 68 | 69 | @tag root_paths: ["proj_one", "proj_two"] 70 | test "stops the runtime when you remove a workspace folder", %{client: client, cwd: cwd} = context do 71 | assert :ok == notify(client, %{method: "initialized", jsonrpc: "2.0", params: %{}}) 72 | assert_is_ready(context, "proj_one") 73 | assert_is_ready(context, "proj_two") 74 | 75 | assert_compiled(context, "proj_one") 76 | assert_compiled(context, "proj_two") 77 | 78 | notify(client, %{ 79 | method: "workspace/didChangeWorkspaceFolders", 80 | jsonrpc: "2.0", 81 | params: %{ 82 | event: %{ 83 | added: [], 84 | removed: [ 85 | %{name: "#{context.module}-proj_two", uri: "file://#{Path.join(cwd, "proj_two")}"} 86 | ] 87 | } 88 | } 89 | }) 90 | 91 | message = "[Next LS] The runtime for #{context.module}-proj_two has successfully shut down." 92 | 93 | assert_notification "window/logMessage", %{ 94 | "message" => ^message 95 | } 96 | end 97 | 98 | @tag root_paths: ["proj_one"] 99 | test "can register for workspace/didChangedWatchedFiles", %{client: client} = context do 100 | assert :ok == notify(client, %{method: "initialized", jsonrpc: "2.0", params: %{}}) 101 | 102 | # assert_request(client, "client/registerCapability", fn params -> 103 | # assert params == %{ 104 | # "registrations" => [ 105 | # %{ 106 | # "id" => "file-watching", 107 | # "method" => "workspace/didChangeWatchedFiles", 108 | # "registerOptions" => %{ 109 | # "watchers" => [ 110 | # %{"kind" => 7, "globPattern" => "**/*.ex"}, 111 | # %{"kind" => 7, "globPattern" => "**/*.exs"}, 112 | # %{"kind" => 7, "globPattern" => "**/*.leex"}, 113 | # %{"kind" => 7, "globPattern" => "**/*.eex"}, 114 | # %{"kind" => 7, "globPattern" => "**/*.heex"}, 115 | # %{"kind" => 7, "globPattern" => "**/*.sface"} 116 | # ] 117 | # } 118 | # } 119 | # ] 120 | # } 121 | 122 | # nil 123 | # end) 124 | 125 | assert_is_ready(context, "proj_one") 126 | assert_compiled(context, "proj_one") 127 | end 128 | 129 | @tag root_paths: ["proj_one"] 130 | test "can receive workspace/didChangeWatchedFiles notification", %{client: client, cwd: cwd} = context do 131 | assert :ok == notify(client, %{method: "initialized", jsonrpc: "2.0", params: %{}}) 132 | 133 | assert_is_ready(context, "proj_one") 134 | assert_compiled(context, "proj_one") 135 | 136 | notify(client, %{ 137 | method: "workspace/didChangeWatchedFiles", 138 | jsonrpc: "2.0", 139 | params: %{changes: [%{type: 3, uri: "file://#{Path.join(cwd, "proj_one/lib/peace.ex")}"}]} 140 | }) 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /test/support/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule NextLS.Support.Utils do 2 | @moduledoc false 3 | import ExUnit.Assertions 4 | import ExUnit.Callbacks 5 | import GenLSP.Test 6 | 7 | alias GenLSP.Structures.Position 8 | alias GenLSP.Structures.Range 9 | alias GenLSP.Structures.TextEdit 10 | 11 | def mix_exs do 12 | """ 13 | defmodule Project.MixProject do 14 | use Mix.Project 15 | 16 | def project do 17 | [ 18 | app: :project, 19 | version: "0.1.0", 20 | elixir: "~> 1.10", 21 | start_permanent: Mix.env() == :prod, 22 | deps: deps() 23 | ] 24 | end 25 | 26 | # Run "mix help compile.app" to learn about applications. 27 | def application do 28 | [ 29 | extra_applications: [:logger] 30 | ] 31 | end 32 | 33 | # Run "mix help deps" to learn about dependencies. 34 | defp deps do 35 | [] 36 | end 37 | end 38 | """ 39 | end 40 | 41 | def with_lsp(%{tmp_dir: tmp_dir} = context) do 42 | root_paths = 43 | for path <- context[:root_paths] || [""] do 44 | Path.absname(Path.join(tmp_dir, path)) 45 | end 46 | 47 | bundle_base = Path.join(tmp_dir, ".bundled") 48 | mixhome = Path.join(tmp_dir, ".mix") 49 | mixarchives = Path.join(mixhome, "archives") 50 | File.mkdir_p!(bundle_base) 51 | 52 | tvisor = start_supervised!(Supervisor.child_spec(Task.Supervisor, id: :one)) 53 | r_tvisor = start_supervised!(Supervisor.child_spec(Task.Supervisor, id: :two)) 54 | rvisor = start_supervised!({DynamicSupervisor, [strategy: :one_for_one]}, id: :three) 55 | start_supervised!({Registry, [keys: :duplicate, name: context.module]}, id: :four) 56 | extensions = [elixir: NextLS.ElixirExtension, credo: NextLS.CredoExtension] 57 | cache = start_supervised!(NextLS.DiagnosticCache, id: :five) 58 | init_options = context[:init_options] || %{} 59 | 60 | pids = [ 61 | :one, 62 | :two, 63 | :three, 64 | :four, 65 | :five 66 | ] 67 | 68 | server = 69 | server(NextLS, 70 | task_supervisor: tvisor, 71 | runtime_task_supervisor: r_tvisor, 72 | dynamic_supervisor: rvisor, 73 | registry: context.module, 74 | extensions: extensions, 75 | cache: cache, 76 | bundle_base: bundle_base, 77 | mix_home: mixhome, 78 | mix_archives: mixarchives 79 | ) 80 | 81 | Process.link(server.lsp) 82 | 83 | client = client(server) 84 | 85 | assert :ok == 86 | request(client, %{ 87 | method: "initialize", 88 | id: 1, 89 | jsonrpc: "2.0", 90 | params: %{ 91 | initializationOptions: init_options, 92 | capabilities: %{ 93 | workspace: %{ 94 | workspaceFolders: true 95 | }, 96 | window: %{ 97 | work_done_progress: false, 98 | showMessage: %{} 99 | } 100 | }, 101 | workspaceFolders: 102 | for( 103 | path <- root_paths, 104 | do: %{uri: "file://#{path}", name: "#{context.module}-#{Path.basename(path)}"} 105 | ) 106 | } 107 | }) 108 | 109 | [server: server, client: client, pids: pids] 110 | end 111 | 112 | defmacro assert_is_ready( 113 | context, 114 | name, 115 | timeout \\ Application.get_env(:ex_unit, :assert_receive_timeout) 116 | ) do 117 | quote do 118 | message = "[Next LS] Runtime for folder #{unquote(context).module}-#{unquote(name)} is ready..." 119 | 120 | assert_notification "window/logMessage", %{"message" => ^message}, unquote(timeout) 121 | end 122 | end 123 | 124 | defmacro assert_compiled( 125 | context, 126 | name, 127 | timeout \\ Application.get_env(:ex_unit, :assert_receive_timeout) 128 | ) do 129 | quote do 130 | message = "Compiled #{unquote(context).module}-#{unquote(name)}!" 131 | 132 | assert_notification "$/progress", 133 | %{ 134 | "value" => %{ 135 | "kind" => "end", 136 | "message" => ^message 137 | } 138 | }, 139 | unquote(timeout) 140 | end 141 | end 142 | 143 | def uri(path) when is_binary(path) do 144 | URI.to_string(%URI{ 145 | scheme: "file", 146 | host: "", 147 | path: path 148 | }) 149 | end 150 | 151 | defmacro assert_result2( 152 | id, 153 | pattern, 154 | timeout \\ Application.get_env(:ex_unit, :assert_receive_timeout) 155 | ) do 156 | quote do 157 | assert_receive %{ 158 | "jsonrpc" => "2.0", 159 | "id" => unquote(id), 160 | "result" => result 161 | }, 162 | unquote(timeout) 163 | 164 | assert result == unquote(pattern) 165 | end 166 | end 167 | 168 | defmacro did_open(client, file_path, text) do 169 | quote do 170 | assert :ok == 171 | notify(unquote(client), %{ 172 | method: "textDocument/didOpen", 173 | jsonrpc: "2.0", 174 | params: %{ 175 | textDocument: %{ 176 | uri: uri(unquote(file_path)), 177 | text: unquote(text), 178 | languageId: "elixir", 179 | version: 1 180 | } 181 | } 182 | }) 183 | end 184 | end 185 | 186 | def apply_edit(code, edit) when is_binary(code), do: apply_edit(String.split(code, "\n"), edit) 187 | 188 | def apply_edit(lines, %TextEdit{} = edit) when is_list(lines) do 189 | text = edit.new_text 190 | %Range{start: %Position{line: startl, character: startc}, end: %Position{line: endl, character: endc}} = edit.range 191 | 192 | startl_text = Enum.at(lines, startl) 193 | prefix = String.slice(startl_text, 0, startc) 194 | 195 | endl_text = Enum.at(lines, endl) 196 | suffix = String.slice(endl_text, endc, String.length(endl_text) - endc) 197 | 198 | replacement = prefix <> text <> suffix 199 | 200 | new_lines = Enum.slice(lines, 0, startl) ++ [replacement] ++ Enum.slice(lines, endl + 1, Enum.count(lines)) 201 | 202 | new_lines 203 | |> Enum.join("\n") 204 | |> String.trim() 205 | end 206 | 207 | defmacro assert_is_text_edit(code, edit, expected) do 208 | quote do 209 | actual = unquote(__MODULE__).apply_edit(unquote(code), unquote(edit)) 210 | assert actual == unquote(expected) 211 | end 212 | end 213 | end 214 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Logger.configure(level: :warning) 2 | 3 | timeout = 4 | if System.get_env("CI", "false") == "true" do 5 | Application.put_env(:next_ls, :indexing_timeout, 500) 6 | 60_000 7 | else 8 | 30_000 9 | end 10 | 11 | ExUnit.start( 12 | exclude: [pending: true], 13 | assert_receive_timeout: timeout, 14 | timeout: 120_000 15 | ) 16 | --------------------------------------------------------------------------------