├── .github ├── dependabot.yml └── workflows │ ├── main.yml │ └── release.yml ├── .gitignore ├── .gitmodules ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── DEVELOPMENT.md ├── ISSUE_TEMPLATE.md ├── LICENSE ├── README.md ├── biome.json ├── eex-language-configuration.json ├── elixir-language-configuration.json ├── images ├── language-server-override.png ├── logo-fullsize.png ├── logo.png ├── screenshot.png ├── test_lens_example.gif └── viewing-elixir-ls-output.gif ├── package-lock.json ├── package.json ├── src ├── commands.ts ├── commands │ ├── copyDebugInfo.ts │ ├── expandMacro.ts │ ├── manipulatePipes.ts │ ├── mixClean.ts │ ├── restart.ts │ └── runTest.ts ├── conflictingExtensions.ts ├── constants.ts ├── debugAdapter.ts ├── executable.ts ├── extension.ts ├── languageClientManager.ts ├── project.ts ├── taskProvider.ts ├── telemetry.ts ├── terminalLinkProvider.ts ├── test-fixtures │ ├── containing_folder │ │ └── single_folder_mix │ │ │ ├── .formatter.exs │ │ │ ├── .gitignore │ │ │ ├── README.md │ │ │ ├── lib │ │ │ └── single_folder_mix.ex │ │ │ ├── mix.exs │ │ │ └── test │ │ │ ├── single_folder_mix_test.exs │ │ │ └── test_helper.exs │ ├── containing_folder_with_mix │ │ ├── mix.exs │ │ └── single_folder_mix │ │ │ ├── .formatter.exs │ │ │ ├── .gitignore │ │ │ ├── README.md │ │ │ ├── lib │ │ │ └── single_folder_mix.ex │ │ │ ├── mix.exs │ │ │ └── test │ │ │ ├── single_folder_mix_test.exs │ │ │ └── test_helper.exs │ ├── elixir_file.ex │ ├── elixir_script.exs │ ├── multi_root.code-workspace │ ├── non_elixir.txt │ ├── sample_umbrella │ │ ├── .formatter.exs │ │ ├── .gitignore │ │ ├── README.md │ │ ├── apps │ │ │ ├── child1 │ │ │ │ ├── .formatter.exs │ │ │ │ ├── .gitignore │ │ │ │ ├── README.md │ │ │ │ ├── lib │ │ │ │ │ └── child1.ex │ │ │ │ ├── mix.exs │ │ │ │ └── test │ │ │ │ │ ├── child1_test.exs │ │ │ │ │ └── test_helper.exs │ │ │ └── child2 │ │ │ │ ├── .formatter.exs │ │ │ │ ├── .gitignore │ │ │ │ ├── README.md │ │ │ │ ├── lib │ │ │ │ └── child2.ex │ │ │ │ ├── mix.exs │ │ │ │ └── test │ │ │ │ ├── child2_test.exs │ │ │ │ └── test_helper.exs │ │ ├── config │ │ │ └── config.exs │ │ └── mix.exs │ ├── single_folder_mix │ │ ├── .formatter.exs │ │ ├── .gitignore │ │ ├── README.md │ │ ├── lib │ │ │ └── single_folder_mix.ex │ │ ├── mix.exs │ │ └── test │ │ │ ├── single_folder_mix_test.exs │ │ │ └── test_helper.exs │ └── single_folder_no_mix │ │ ├── elixir_file.ex │ │ └── elixir_script.exs ├── test │ ├── multiRoot │ │ ├── extension.test.ts │ │ └── index.ts │ ├── noWorkspace │ │ ├── extension.test.ts │ │ └── index.ts │ ├── noWorkspaceElixirFile │ │ ├── extension.test.ts │ │ └── index.ts │ ├── runTest.ts │ ├── singleFolderMix │ │ ├── extension.test.ts │ │ └── index.ts │ ├── singleFolderNoMix │ │ ├── extension.test.ts │ │ └── index.ts │ └── utils.ts ├── testController.ts └── testElixir.ts ├── syntaxes ├── eex.json ├── elixir.json └── html-eex.json ├── telemetry.json └── tsconfig.json /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | push: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | # TODO test on macos-latest? 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: [ubuntu-22.04, windows-2022] 18 | runs-on: ${{ matrix.os }} 19 | 20 | steps: 21 | - name: Checkout source 22 | uses: actions/checkout@v4 23 | with: 24 | submodules: 'true' 25 | - name: Install Node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: "20.x" 29 | - name: Resolve vscode-elixir-ls dependencies 30 | run: | 31 | npm ci 32 | - name: Static analysis 33 | run: | 34 | npm run lint 35 | if: runner.os != 'Windows' 36 | - name: Build 37 | run: | 38 | npm run compile 39 | - name: Setup Elixir 40 | uses: erlef/setup-beam@v1 41 | with: 42 | elixir-version: 1.18.x 43 | otp-version: 27.x 44 | - name: Resolve elixir-ls dependencies 45 | run: | 46 | cd elixir-ls 47 | mix deps.get 48 | env: 49 | MIX_ENV: "prod" 50 | - name: Install local release 51 | run: | 52 | elixir "elixir-ls/scripts/quiet_install.exs" 53 | env: 54 | MIX_ENV: "prod" 55 | ELS_LOCAL: 1 56 | - name: Run tests 57 | run: | 58 | xvfb-run -a npm test 59 | env: 60 | # DISABLE_GPU: 1 61 | ELS_LOCAL: 1 62 | if: runner.os == 'Linux' 63 | - name: Run tests 64 | run: | 65 | npm test 66 | env: 67 | # DISABLE_GPU: 1 68 | ELS_LOCAL: 1 69 | if: runner.os != 'Linux' 70 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - "*" 5 | 6 | name: Deploy Extension 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-22.04 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | submodules: 'true' 14 | - name: validate version 15 | run: | 16 | VERSION=$(jq -r .version package.json) 17 | if [[ "$GITHUB_REF_NAME" != "v$VERSION" ]]; then 18 | echo "package.json version $VERSION does not match commit tag $GITHUB_REF_NAME" 19 | exit 1 20 | fi 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: "20.x" 24 | - name: Setup Elixir 25 | uses: erlef/setup-beam@v1 26 | with: 27 | elixir-version: 1.18.x 28 | otp-version: 27.x 29 | - run: npm ci 30 | - run: cd elixir-ls && mix deps.get 31 | 32 | - name: Dry run 33 | uses: HaaLeo/publish-vscode-extension@v2 34 | if: ${{ contains(github.ref, '-rc.') }} 35 | with: 36 | pat: "dummy" 37 | preRelease: true 38 | dryRun: true 39 | 40 | - name: set publisher for Visual Studio Marketplace 41 | if: ${{ !contains(github.ref, '-rc.') }} 42 | run: | 43 | jq '.publisher = "JakeBecker"' package.json > package_temp.json 44 | mv package_temp.json package.json 45 | - name: Publish to Visual Studio Marketplace 46 | uses: HaaLeo/publish-vscode-extension@v2 47 | if: ${{ !contains(github.ref, '-rc.') }} 48 | with: 49 | pat: ${{ secrets.VS_MARKETPLACE_TOKEN }} 50 | registryUrl: https://marketplace.visualstudio.com 51 | # preRelease: ${{ contains(github.ref, '-rc.') }} 52 | - name: set publisher for Open VSX Registry 53 | if: ${{ !contains(github.ref, '-rc.') }} 54 | run: | 55 | jq '.publisher = "elixir-lsp"' package.json > package_temp.json 56 | mv package_temp.json package.json 57 | - name: Publish to Open VSX Registry 58 | uses: HaaLeo/publish-vscode-extension@v2 59 | if: ${{ !contains(github.ref, '-rc.') }} 60 | with: 61 | pat: ${{ secrets.OPEN_VSX_TOKEN }} 62 | # preRelease: ${{ contains(github.ref, '-rc.') }} 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules 3 | .vscode-test/ 4 | *.vsix 5 | **/erl_crash.dump 6 | elixir-ls-release/ 7 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "elixir-ls"] 2 | path = elixir-ls 3 | url = https://github.com/elixir-lsp/elixir-ls 4 | branch = master 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | 5 | // List of extensions which should be recommended for users of this workspace. 6 | "recommendations": [], 7 | 8 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 9 | "unwantedRecommendations": [] 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | { 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "Launch Extension local", 7 | "type": "extensionHost", 8 | "request": "launch", 9 | "runtimeExecutable": "${execPath}", 10 | "args": [ 11 | "--disable-extensions", 12 | "--extensionDevelopmentPath=${workspaceFolder}" 13 | ], 14 | "env": { 15 | "ELS_LOCAL": "1" 16 | }, 17 | "sourceMaps": true, 18 | "outFiles": ["${workspaceFolder}/out/**/*.js"], 19 | "preLaunchTask": "build" 20 | }, 21 | { 22 | "name": "Launch Extension local no disable extensions", 23 | "type": "extensionHost", 24 | "request": "launch", 25 | "runtimeExecutable": "${execPath}", 26 | "args": [ 27 | "--extensionDevelopmentPath=${workspaceFolder}" 28 | ], 29 | "env": { 30 | "ELS_LOCAL": "1" 31 | }, 32 | "sourceMaps": true, 33 | "outFiles": ["${workspaceFolder}/out/**/*.js"], 34 | "preLaunchTask": "build" 35 | }, 36 | { 37 | "name": "Launch Extension release", 38 | "type": "extensionHost", 39 | "request": "launch", 40 | "runtimeExecutable": "${execPath}", 41 | "args": [ 42 | "--disable-extensions", 43 | "--extensionDevelopmentPath=${workspaceFolder}" 44 | ], 45 | "env": { 46 | }, 47 | "sourceMaps": true, 48 | "outFiles": ["${workspaceFolder}/out/**/*.js"], 49 | "preLaunchTask": "build" 50 | }, 51 | { 52 | "name": "Launch noWorkspaceElixirFile Tests", 53 | "type": "extensionHost", 54 | "request": "launch", 55 | "runtimeExecutable": "${execPath}", 56 | "args": [ 57 | "--disable-extensions", 58 | "--extensionDevelopmentPath=${workspaceFolder}", 59 | "--extensionTestsPath=${workspaceFolder}/out/test/noWorkspaceElixirFile", 60 | "${workspaceFolder}/src/test-fixtures/elixir_script.exs" 61 | ], 62 | "env": { 63 | "ELS_TEST": "1" 64 | }, 65 | "sourceMaps": true, 66 | "outFiles": ["${workspaceFolder}/out/**/*.js"], 67 | "preLaunchTask": "build" 68 | }, 69 | { 70 | "name": "Launch noWorkspace Tests", 71 | "type": "extensionHost", 72 | "request": "launch", 73 | "runtimeExecutable": "${execPath}", 74 | "args": [ 75 | "--disable-extensions", 76 | "--extensionDevelopmentPath=${workspaceFolder}", 77 | "--extensionTestsPath=${workspaceFolder}/out/test/noWorkspace", 78 | "${workspaceFolder}/src/test-fixtures/non_elixir.txt" 79 | ], 80 | "env": { 81 | "ELS_TEST": "1" 82 | }, 83 | "sourceMaps": true, 84 | "outFiles": ["${workspaceFolder}/out/**/*.js"], 85 | "preLaunchTask": "build" 86 | }, 87 | { 88 | "name": "Launch singleFolderNoMix Tests", 89 | "type": "extensionHost", 90 | "request": "launch", 91 | "runtimeExecutable": "${execPath}", 92 | "args": [ 93 | "--disable-extensions", 94 | "--extensionDevelopmentPath=${workspaceFolder}", 95 | "--extensionTestsPath=${workspaceFolder}/out/test/singleFolderNoMix", 96 | "${workspaceFolder}/src/test-fixtures/single_folder_no_mix" 97 | ], 98 | "env": { 99 | "ELS_TEST": "1" 100 | }, 101 | "sourceMaps": true, 102 | "outFiles": ["${workspaceFolder}/out/**/*.js"], 103 | "preLaunchTask": "build" 104 | }, 105 | { 106 | "name": "Launch singleFolderMix Tests", 107 | "type": "extensionHost", 108 | "request": "launch", 109 | "runtimeExecutable": "${execPath}", 110 | "args": [ 111 | "--disable-extensions", 112 | "--extensionDevelopmentPath=${workspaceFolder}", 113 | "--extensionTestsPath=${workspaceFolder}/out/test/singleFolderMix", 114 | "${workspaceFolder}/src/test-fixtures/single_folder_mix" 115 | ], 116 | "env": { 117 | "ELS_TEST": "1" 118 | }, 119 | "sourceMaps": true, 120 | "outFiles": ["${workspaceFolder}/out/**/*.js"], 121 | "preLaunchTask": "build" 122 | }, 123 | { 124 | "name": "Launch multiRoot Tests", 125 | "type": "extensionHost", 126 | "request": "launch", 127 | "runtimeExecutable": "${execPath}", 128 | "args": [ 129 | "--disable-extensions", 130 | "--extensionDevelopmentPath=${workspaceFolder}", 131 | "--extensionTestsPath=${workspaceFolder}/out/test/multiRoot", 132 | "${workspaceFolder}/src/test-fixtures/multi_root.code-workspace" 133 | ], 134 | "env": { 135 | "ELS_TEST": "1" 136 | }, 137 | "sourceMaps": true, 138 | "outFiles": ["${workspaceFolder}/out/**/*.js"], 139 | "preLaunchTask": "build" 140 | } 141 | ] 142 | } 143 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | "typescript.tsdk": "./node_modules/typescript/lib", // we want to use the TS server from our node_modules folder to control its version 10 | "elixirLS.projectDir": "elixir-ls" 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // Available variables which can be used inside of strings. 2 | // ${workspaceRoot}: the root folder of the team 3 | // ${file}: the current opened file 4 | // ${fileBasename}: the current opened file's basename 5 | // ${fileDirname}: the current opened file's dirname 6 | // ${fileExtname}: the current opened file's extension 7 | // ${cwd}: the current working directory of the spawned process 8 | 9 | // A task runner that calls a custom npm script that compiles the extension. 10 | { 11 | "version": "2.0.0", 12 | "tasks": [ 13 | { 14 | "label": "build", 15 | "type": "shell", 16 | "command": "npm", 17 | "args": ["run", "esbuild"], 18 | "group": "build", 19 | "problemMatcher": "$tsc-watch", 20 | "presentation": { 21 | "echo": true, 22 | "reveal": "always", 23 | "focus": false, 24 | "panel": "shared", 25 | "showReuseMessage": true, 26 | "clear": false 27 | } 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | out/test/** 4 | test/** 5 | src/** 6 | **/*.map 7 | node_modules 8 | .gitignore 9 | tsconfig.json 10 | elixir-ls/** 11 | .github/** 12 | .gitignore 13 | tsconfig.json 14 | .vscodeignore 15 | .eslint* 16 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | Contact: elixir-ls-coc@googlegroups.com 4 | 5 | ## Why have a Code of Conduct? 6 | 7 | As contributors and maintainers of this project, we are committed to providing a friendly, safe and welcoming environment for all, regardless of age, disability, gender, nationality, race, religion, sexuality, or similar personal characteristic. 8 | 9 | The goal of the Code of Conduct is to specify a baseline standard of behavior so that people with different social values and communication styles can talk about Elixir effectively, productively, and respectfully, even in face of disagreements. The Code of Conduct also provides a mechanism for resolving conflicts in the community when they arise. 10 | 11 | ## Our Values 12 | 13 | These are the values ElixirLS developers should aspire to: 14 | 15 | * Be friendly and welcoming 16 | * Be kind 17 | * Remember that people have varying communication styles and that not everyone is using their native language. (Meaning and tone can be lost in translation.) 18 | * Interpret the arguments of others in good faith, do not seek to disagree. 19 | * When we do disagree, try to understand why. 20 | * Be thoughtful 21 | * Productive communication requires effort. Think about how your words will be interpreted. 22 | * Remember that sometimes it is best to refrain entirely from commenting. 23 | * Be respectful 24 | * In particular, respect differences of opinion. It is important that we resolve disagreements and differing views constructively. 25 | * Be constructive 26 | * Avoid derailing: stay on topic; if you want to talk about something else, start a new conversation. 27 | * Avoid unconstructive criticism: don't merely decry the current state of affairs; offer — or at least solicit — suggestions as to how things may be improved. 28 | * Avoid harsh words and stern tone: we are all aligned towards the well-being of the community and the progress of the ecosystem. Harsh words exclude, demotivate, and lead to unnecessary conflict. 29 | * Avoid snarking (pithy, unproductive, sniping comments). 30 | * Avoid microaggressions (brief and commonplace verbal, behavioral and environmental indignities that communicate hostile, derogatory or negative slights and insults towards a project, person or group). 31 | * Be responsible 32 | * What you say and do matters. Take responsibility for your words and actions, including their consequences, whether intended or otherwise. 33 | 34 | The following actions are explicitly forbidden: 35 | 36 | * Insulting, demeaning, hateful, or threatening remarks. 37 | * Discrimination based on age, disability, gender, nationality, race, religion, sexuality, or similar personal characteristic. 38 | * Bullying or systematic harassment. 39 | * Unwelcome sexual advances. 40 | * Incitement to any of these. 41 | 42 | ## Where does the Code of Conduct apply? 43 | 44 | If you participate in or contribute to the ElixirLS in any way, you are encouraged to follow the Code of Conduct while doing so. 45 | 46 | Explicit enforcement of the Code of Conduct applies to the official mediums operated by the ElixirLS project: 47 | 48 | * The [official GitHub projects][1] and code reviews. 49 | 50 | Other ElixirLS activities (such as conferences, meetups, and unofficial forums) are encouraged to adopt this Code of Conduct. Such groups must provide their own contact information. 51 | 52 | Project maintainers may block, remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. 53 | 54 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by emailing: elixir-ls-coc@googlegroups.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. **All reports will be kept confidential**. 55 | 56 | **The goal of the Code of Conduct is to resolve conflicts in the most harmonious way possible**. We hope that in most cases issues may be resolved through polite discussion and mutual agreement. Bannings and other forceful measures are to be employed only as a last resort. **Do not** post about the issue publicly or try to rally sentiment against a particular individual or group. 57 | 58 | ## Acknowledgements 59 | 60 | This document was based on the Code of Conduct from the Go project (dated Sep/2021) and the Contributor Covenant (v1.4). 61 | 62 | [1]: https://github.com/elixir-lsp/ 63 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | ## Packaging 4 | 5 | 1. Update the elixir-ls submodule `git submodule foreach git pull origin master` to desired tag 6 | 2. Update version in `package.json` (to e.g. `0.15.0`) 7 | 3. Update [CHANGELOG.md](CHANGELOG.md) 8 | 4. Test the new vscode-elixir-ls version with: 9 | 10 | ```shell 11 | npm install 12 | npm install -g @vscode/vsce@latest 13 | vsce package 14 | code --install-extension ./elixir-ls-*.vsix --force 15 | ``` 16 | 17 | 5. Push and verify the build is green. 18 | 6. Tag and push tags. Tag needs to be version prefixed with `v` (e.g. `v0.15.0`). Github action will create and publish the release to Visual Studio Marketplace and Open VSX Registry. Semver prerelease tags (e.g. `v0.1.0-rc.0`) will dry run publish. 19 | 7. Update forum announcement post: https://elixirforum.com/t/introducing-elixirls-the-elixir-language-server/5857 20 | 21 | ## Updating allowed dialyzer options 22 | 23 | The list in [project.json] needs to be updated to accommodate for changes in OTP basing on the list from https://github.com/erlang/otp/blob/412bff5196fc0ab88a61fe37ca30e5226fc7872d/lib/dialyzer/src/dialyzer_options.erl#L495 24 | 25 | ## References 26 | 27 | https://code.visualstudio.com/api/working-with-extensions/publishing-extension 28 | 29 | Personal Access Token (PAT) direct link: https://dev.azure.com/elixir-lsp/_usersSettings/tokens 30 | 31 | https://code.visualstudio.com/api/language-extensions/embedded-languages 32 | 33 | ## VSCode grammar debugging 34 | 35 | Run "Developer: Inspect Editor Tokens and Scopes" when you want to debug the textmate grammar 36 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Is this the right repo? 2 | 3 | Most of the functionality for this extension comes from ElixirLS: https://github.com/elixir-lsp/elixir-ls 4 | 5 | Please file your issue on this repo (vscode-elixir-ls) only if it has something to do with VS Code specifically and would not apply to other IDEs running ElixirLS. Otherwise, file it here: https://github.com/elixir-lsp/elixir-ls/issues 6 | 7 | If the language server fails to launch, the problem is most likely in ElixirLS, so please file the issue on that repo. 8 | 9 | ### Environment 10 | 11 | Most of this can be filled out by running the VSCode command "Elixir: Copy ElixirLS Debug Info". You can run the command by opening the Command Palette in VSCode (by default bound to Ctrl+Shift+P or Cmd+Shift+P on Mac), typing search terms like "elixir copy debug" until the command appears at the top of the list of results, and then hitting Enter. The info will be copied to the clipboard so you can paste it here. 12 | 13 | - Elixir & Erlang versions (elixir --version): 14 | - VSCode ElixirLS version: 15 | - Operating System Version: 16 | 17 | ### Troubleshooting 18 | 19 | - [ ] Restart your editor (which will restart ElixirLS) sometimes fixes issues 20 | - [ ] Stop your editor, remove the entire `.elixir_ls` directory, then restart your editor 21 | 22 | ### Crash report template 23 | 24 | _Delete this section if not reporting a crash_ 25 | 26 | 1. Create a new Mix project with `mix new empty`, then open that project with VS Code and open an Elixir file. Is your issue reproducible on the empty project? If not, please publish a repo on Github that does reproduce it. 27 | 2. Check the output log by opening `View > Output` and selecting "ElixirLS" in the dropdown. Please include any output that looks relevant. (If ElixirLS isn't in the dropdown, the server failed to launch.) 28 | 3. Check the developer console by opening `Help > Toggle Developer Tools` and include any errors that look relevant. 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2022 The Elixir community 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ElixirLS: Elixir support and debugger for VS Code 2 | [![Actions Status](https://img.shields.io/github/actions/workflow/status/elixir-lsp/vscode-elixir-ls/main.yml?branch=master)](github/actions/workflow/status/elixir-lsp/vscode-elixir-ls/main.yml?branch=master) 3 | [![Visual Studio Marketplace Installs](https://img.shields.io/visual-studio-marketplace/i/JakeBecker.elixir-ls?label=Visual%20Studio%20Marketplace%20Installs)](https://marketplace.visualstudio.com/items?itemName=JakeBecker.elixir-ls) 4 | [![Open VSX Installs](https://img.shields.io/open-vsx/dt/elixir-lsp/elixir-ls?label=Open%20VSX%20Installs)](https://open-vsx.org/extension/elixir-lsp/elixir-ls) 5 | [![Slack](https://img.shields.io/badge/slack-join-orange.svg)](https://elixir-lang.slack.com/archives/C7D272G6N) 6 | 7 | Provides Elixir language server and debug adapter. This extension is powered by the [Elixir Language Server (ElixirLS)](https://github.com/elixir-lsp/elixir-ls), an Elixir implementation of Microsoft's IDE-agnostic [Language Server Protocol](https://microsoft.github.io/language-server-protocol/) and [Debug Adapter Protocol](https://microsoft.github.io/debug-adapter-protocol/). Visit its page for more information. For a guide to debugger usage in Elixir, read [this blog post](https://medium.com/@JakeBeckerCode/debugging-elixir-in-vs-code-400e21814614). 8 | 9 | Features include: 10 | 11 | - Code completion 12 | - Debugger support [VSCode debugging docs](https://code.visualstudio.com/docs/editor/debugging) 13 | - Test discovery, running and debugging via Test Explorer [VSCode test API announcment](https://code.visualstudio.com/updates/v1_59#_testing-apis) 14 | - Automatic, incremental Dialyzer analysis 15 | - Automatic suggestion for @spec annotations based on Dialyzer's inferred success typings 16 | - Diagnostic reporting of build warnings and errors 17 | - Go-to-definition and Go-to-implementation 18 | - Task provider with collection of mix tasks [VSCode tasks](https://code.visualstudio.com/docs/editor/tasks) 19 | - Smart automatic closing of code blocks 20 | - Documentation lookup on hover 21 | - Function signature provider 22 | - Code formatter (Triggered by `Alt + Shift + F` hotkey or enabling `editor.formatOnSave`) 23 | - Find references to functions and modules 24 | - Document and Workspace symbols provider 25 | - Multi-root workspaces 26 | 27 | ![Screenshot](https://raw.githubusercontent.com/elixir-lsp/elixir-ls/master/images/screenshot.png) 28 | 29 | ## This is the main vscode-elixir-ls repo 30 | 31 | The [elixir-lsp](https://github.com/elixir-lsp)/[vscode-elixir-ls](https://github.com/elixir-lsp/vscode-elixir-ls) repo began as a fork when the original repo at [JakeBecker](https://github.com/JakeBecker)/[vscode-elixir-ls](https://github.com/JakeBecker/vscode-elixir-ls) became inactive for an extended period of time. So we decided to start an active fork to merge dormant PR's and fix issues where possible. We also believe in an open and shared governance model to share the work instead of relying on one person to shoulder the whole burden. 32 | 33 | The original repository has now been deprecated in favor of this one. Future updates to the original [VS Code ElixirLS extension](https://marketplace.visualstudio.com/items?itemName=JakeBecker.elixir-ls) will come from this repo. 34 | 35 | ## Default settings 36 | 37 | ElixirLS is opinionated and sets the following default settings for Elixir files: 38 | 39 | ```jsonc 40 | { 41 | // Based on Elixir formatter's style 42 | "editor.insertSpaces": true, 43 | // Note: While it is possible to override this in your VSCode configuration, the Elixir Formatter 44 | // does not support a configurable tab size, so if you override this then you should not use the 45 | // formatter. 46 | "editor.tabSize": 2, 47 | "files.trimTrailingWhitespace": true, 48 | "files.insertFinalNewline": true, 49 | "files.trimFinalNewlines": true, 50 | 51 | // Provides smart completion for "do" and "fn ->" blocks. Does not run the Elixir formatter. 52 | "editor.formatOnType": true, 53 | 54 | // Misc 55 | "editor.wordBasedSuggestions": false, 56 | "editor.trimAutoWhitespace": false 57 | } 58 | ``` 59 | 60 | You can, of course, change these in your user settings, or on a per project basis in `.vscode/settings.json`. 61 | 62 | ## Advanced Configuration 63 | 64 | ### Customizing launch config for test runner 65 | 66 | The test runner builds a launch configuration dynamically basing on hardcoded default values: 67 | 68 | ```js 69 | { 70 | "type": "mix_task", 71 | "name": "mix test", 72 | "request": "launch", 73 | "task": "test", 74 | "env": { 75 | "MIX_ENV": "test", 76 | }, 77 | "taskArgs": buildTestCommandArgs(args, debug), 78 | "startApps": true, 79 | "projectDir": args.cwd, 80 | // we need to require all test helpers and only the file we need to test 81 | // mix test runs tests in all required files even if they do not match 82 | // given path:line 83 | "requireFiles": [ 84 | "test/**/test_helper.exs", 85 | "apps/*/test/**/test_helper.exs", 86 | args.filePath, 87 | ], 88 | "noDebug": !debug, 89 | } 90 | ``` 91 | 92 | The default launch config can be customized by providing a project launch configuration named `mix test`. If found, this launch config is used as default for running and debugging tests. 93 | 94 | Example: 95 | 96 | ```json 97 | { 98 | "type": "mix_task", 99 | "name": "mix test", 100 | "request": "launch", 101 | "debugAutoInterpretAllModules": false, 102 | "debugInterpretModulesPatterns": ["MyApp*"] 103 | } 104 | ``` 105 | 106 | ### Add support for emmet 107 | 108 | `emmet` is a plugin that makes it easier to write HTML: https://code.visualstudio.com/docs/editor/emmet 109 | 110 | Open VSCode and hit Ctrl+Shift+P (or Cmd+Shift+P) and type "Preference: Open Settings (JSON)" 111 | Add or edit your `emmet.includedLanguages` to include the new Language ID: 112 | 113 | ```json 114 | "emmet.includeLanguages": { 115 | "html-eex": "html" 116 | } 117 | ``` 118 | 119 | ## Supported versions 120 | 121 | See [ElixirLS](https://github.com/elixir-lsp/elixir-ls) for details on the supported Elixir and Erlang versions. 122 | 123 | ## Troubleshooting 124 | 125 | If you run into issues with the extension, try these debugging steps: 126 | 127 | - Make sure you have `hex` and `git` installed. 128 | - Make sure `github.com` and `hex.pm` are accessible. You may need to configure your HTTPS proxy. If your setup uses TLS man-in-the-middle inspection, you may need to set `HEX_UNSAFE_HTTPS=1`. 129 | - If ElixirLS fails to start, you can try cleaning the `Mix.install` directory. (The location on your system can be obtained by calling `Path.join(Mix.Utils.mix_cache(), "installs")` from an `iex` session.) 130 | - Restart ElixirLS with a custom command `restart` 131 | - Run `mix clean` or `mix clean --deps` in ElixirLS with the custom command `mixClean`. 132 | - Restart your editor (which will restart ElixirLS). 133 | - After stopping your editor, remove the entire `.elixir_ls` directory, then restart your editor. 134 | - NOTE: This will cause you to have to re-run the entire dialyzer build 135 | 136 | You may need to set `elixirLS.mixEnv`, `elixirLS.mixTarget`, `elixirLS.projectDir` and/or `elixirLS.useCurrentRootFolderAsProjectDir` if your project requires it. By default, ElixirLS compiles code with `MIX_ENV=test`, `MIX_TARGET=host`, and assumes that `mix.exs` is located in the workspace root directory. 137 | 138 | If you get an error like the following immediately on startup: 139 | 140 | ``` 141 | [Warn - 1:56:04 PM] ** (exit) exited in: GenServer.call(ElixirLS.LanguageServer.JsonRpc, {:packet, %{...snip...}}, 5000) 142 | ** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started 143 | ``` 144 | 145 | and you installed Elixir and Erlang from the Erlang Solutions repository, you may not have a full installation of Erlang. This can be solved with `sudo apt-get install esl-erlang`. (This was originally reported in [#208](https://github.com/elixir-lsp/elixir-ls/issues/208)). 146 | 147 | On fedora if you only install the elixir package you will not have a full erlang installation, this can be fixed by running `sudo dnf install erlang` (This was reported in [#231](https://github.com/elixir-lsp/elixir-ls/issues/231)). 148 | 149 | ### Check ElixirLS Output 150 | 151 | Check the output log by opening `View > Output` and selecting "ElixirLS" in the dropdown. 152 | 153 | ![View ElixirLS Output](images/viewing-elixir-ls-output.gif) 154 | 155 | ### Check the Developer Tools 156 | 157 | Check the developer console by opening `Help > Toggle Developer Tools` and include any errors that look relevant. 158 | 159 | ## Contributing 160 | 161 | ### Installation 162 | 163 | ```shell 164 | # Clone this repo recursively to ensure you get the elixir-ls submodule 165 | git clone --recursive git@github.com:elixir-lsp/vscode-elixir-ls.git 166 | 167 | # Fetch vscode-elixir-ls dependencies 168 | cd vscode-elixir-ls 169 | npm install 170 | 171 | # Fetch elixir-ls dependencies 172 | cd elixir-ls 173 | mix deps.get 174 | MIX_ENV=prod mix compile 175 | ``` 176 | 177 | To launch the extension from VS Code, run the "Launch Extension local" launch configuration from [Run and Debug view](https://code.visualstudio.com/docs/editor/debugging#_run-view) or press F5. 178 | 179 | Alternatively, you can build and install the extension locally using the `vsce` command and the `code` CLI. 180 | 181 | ```shell 182 | # Navigate to vscode-elixir-ls project root 183 | cd .. 184 | 185 | # Build the extension 186 | npx vsce package 187 | 188 | # Install it locally 189 | code --install-extension *.vsix --force 190 | ``` 191 | 192 | Note that if you have the extension installed from the Visual Studio Marketplace and are also installing a locally 193 | built package, you may need to disable the [Extensions: Auto Check Updates](https://code.visualstudio.com/docs/editor/extension-gallery#_extension-autoupdate) setting to prevent your 194 | local install from being replaced with the Marketplace version. 195 | 196 | ### `elixir-ls` submodule 197 | 198 | Most of the functionality of this extension comes from ElixirLS, which is included as a Git submodule in the `elixir-ls` folder. Make sure you clone the repo using `git clone --recursive` or run `git submodule init && git submodule update` after cloning. 199 | 200 | Including `elixir-ls` as a submodule makes it easy to develop and test code changes for ElixirLS itself. If you want to modify ElixirLS, not just its VS Code client code, you'll want to change the code in the `elixir-ls` subdirectory. Most often you don't need to explicitly build it. The ElixirLS launch script should be able to pick up changes and rebuild accordingly via `Mix.install`. 201 | 202 | When you're ready to contribute your changes back to ElixirLS, you need to fork the [ElixirLS](https://github.com/elixir-lsp/elixir-ls) repo on Github and push any changes you make to the ElixirLS submodule to your fork. Here is an example of how that might look: 203 | 204 | ```shell 205 | # Enter the submodule directory. Now, if you run git commands, they run in the submodule 206 | cd vscode-elixir-ls/elixir-ls 207 | 208 | # Create your feature branch 209 | git checkout -b my_new_branch 210 | 211 | # Add your forked elixir-ls repository as a remote 212 | git remote add my_fork git@github.com:/elixir-ls.git 213 | 214 | # Make changes in the elixir-ls folder, commit them, and push to your forked repo 215 | git commit ... 216 | git push my_fork my_new_branch 217 | 218 | # Visit https://github.com/elixir-lsp/elixir-ls/compare to start a new Pull Request 219 | ``` 220 | 221 | ### Running the tests locally 222 | 223 | You should ensure that the tests run locally before submitting a PR, and if relevant add automated tests in the PR. 224 | 225 | ```shell 226 | rm -rf out 227 | npm run compile 228 | npm test 229 | ``` 230 | 231 | Alternatively, you can use the `test.sh`/`test.bat` script which does the above. 232 | 233 | ## Telemetry 234 | 235 | This extension collects telemetry information emitted by ElixirLS language server and debug adapter for feature insight and performance and health monitoring. Collected telemetry data include usage, performance, environment info and error reports. Data is anonymised and not personally identifiable. Data is sent to Azure Application Insights via [@vscode/extension-telemetry](https://www.npmjs.com/package/@vscode/extension-telemetry). The extension respects VSCode `telemetry.telemetryLevel` setting. For transparency [telemetry.json](telemetry.json) details all collected information. If you would like inspect what is being collected or change your telemetry settings, please refer to [VSCode Telemetry documentation](https://code.visualstudio.com/docs/getstarted/telemetry). 236 | 237 | ## Acknowledgements and related projects 238 | 239 | There is another VS Code extension for Elixir, [VSCode Elixir](https://github.com/fr1zle/vscode-elixir). It's powered by [Elixir Sense](https://github.com/msaraiva/elixir_sense), another language "smartness" server similar to ElixirLS. Much of this extension's client code (such as syntax highlighting) was copied directly from VSCode Elixir, for which they deserve all the credit. 240 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.3/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "ignore": [ 11 | "node_modules", 12 | ".vscode-test", 13 | "out", 14 | "syntaxes", 15 | "elixir-ls", 16 | ".vscode", 17 | "elixir-language-configuration.json", 18 | "eex-language-configuration.json", 19 | "telemetry.json" 20 | ] 21 | }, 22 | "formatter": { 23 | "enabled": true, 24 | "indentStyle": "space" 25 | }, 26 | "organizeImports": { 27 | "enabled": true 28 | }, 29 | "linter": { 30 | "enabled": true, 31 | "rules": { 32 | "recommended": true 33 | } 34 | }, 35 | "javascript": { 36 | "formatter": { 37 | "quoteStyle": "double" 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /eex-language-configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "comments": { 3 | "blockComment": [ 4 | ["<%!--", "--%>"], 5 | ["<%#", "%>"] 6 | ] 7 | }, 8 | "brackets": [ 9 | ["<", ">"], 10 | ["{", "}"], 11 | ["(", ")"], 12 | ["[", "]"] 13 | ], 14 | "colorizedBracketPairs": [ 15 | ["{", "}"], 16 | ["[", "]"], 17 | ["(", ")"] 18 | ], 19 | "autoClosingPairs": [ 20 | ["{", "}"], 21 | ["[", "]"], 22 | ["(", ")"], 23 | ["<%", " %>"], 24 | ["'", "'"], 25 | ["\"", "\""] 26 | ], 27 | "surroundingPairs": [ 28 | ["'", "'"], 29 | ["{", "}"], 30 | ["[", "]"], 31 | ["(", ")"], 32 | ["<", ">"], 33 | ["%", "%"], 34 | ["\"", "\""] 35 | ], 36 | "onEnterRules": [ 37 | { 38 | "beforeText": { 39 | "pattern": "<(?!(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr))([_:\\w][_:\\w-.\\d]*)(?:(?:[^'\"/>]|\"[^\"]*\"|'[^']*')*?(?!\\/)>)[^<]*$", 40 | "flags": "i" 41 | }, 42 | "afterText": { 43 | "pattern": "^<\\/([_:\\w][_:\\w-.\\d]*)\\s*>", 44 | "flags": "i" 45 | }, 46 | "action": { 47 | "indent": "indentOutdent" 48 | } 49 | }, 50 | { 51 | "beforeText": "<%#\\s*$", 52 | "action": { 53 | "indent": "indentOutdent" 54 | }, 55 | "afterText": "%>\\s*$" 56 | } 57 | ] 58 | } 59 | -------------------------------------------------------------------------------- /elixir-language-configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "comments": { 3 | "lineComment": "#" 4 | }, 5 | "brackets": [ 6 | ["{", "}"], 7 | ["[", "]"], 8 | ["<<", ">>"], 9 | ["fn", "end"], 10 | ["do", "end"], 11 | ["(", ")"] 12 | ], 13 | "surroundingPairs": [ 14 | ["{", "}"], 15 | ["[", "]"], 16 | ["(", ")"], 17 | ["<", ">"], 18 | ["'", "'"], 19 | ["\"", "\""] 20 | ], 21 | "autoClosingPairs": [ 22 | { "open": "'", "close": "'", "notIn": ["string", "comment"] }, 23 | { "open": "\"", "close": "\"", "notIn": ["comment"] }, 24 | { "open": "~B\"", "close": "\"" }, 25 | { "open": "~C\"", "close": "\"" }, 26 | { "open": "~D\"", "close": "\"" }, 27 | { "open": "~E\"", "close": "\"" }, 28 | { "open": "~F\"", "close": "\"" }, 29 | { "open": "~H\"", "close": "\"" }, 30 | { "open": "~L\"", "close": "\"" }, 31 | { "open": "~N\"", "close": "\"" }, 32 | { "open": "~R\"", "close": "\"" }, 33 | { "open": "~S\"", "close": "\"" }, 34 | { "open": "~T\"", "close": "\"" }, 35 | { "open": "~U\"", "close": "\"" }, 36 | { "open": "~W\"", "close": "\"" }, 37 | { "open": "~b\"", "close": "\"" }, 38 | { "open": "~c\"", "close": "\"" }, 39 | { "open": "~e\"", "close": "\"" }, 40 | { "open": "~r\"", "close": "\"" }, 41 | { "open": "~s\"", "close": "\"" }, 42 | { "open": "~w\"", "close": "\"" }, 43 | 44 | { "open": "~B\/", "close": "\/" }, 45 | { "open": "~C\/", "close": "\/" }, 46 | { "open": "~D\/", "close": "\/" }, 47 | { "open": "~E\/", "close": "\/" }, 48 | { "open": "~F\/", "close": "\/" }, 49 | { "open": "~H\/", "close": "\/" }, 50 | { "open": "~L\/", "close": "\/" }, 51 | { "open": "~N\/", "close": "\/" }, 52 | { "open": "~R\/", "close": "\/" }, 53 | { "open": "~S\/", "close": "\/" }, 54 | { "open": "~T\/", "close": "\/" }, 55 | { "open": "~U\/", "close": "\/" }, 56 | { "open": "~W\/", "close": "\/" }, 57 | { "open": "~b\/", "close": "\/" }, 58 | { "open": "~c\/", "close": "\/" }, 59 | { "open": "~e\/", "close": "\/" }, 60 | { "open": "~r\/", "close": "\/" }, 61 | { "open": "~s\/", "close": "\/" }, 62 | { "open": "~w\/", "close": "\/" }, 63 | 64 | { "open": "~B|", "close": "|" }, 65 | { "open": "~C|", "close": "|" }, 66 | { "open": "~D|", "close": "|" }, 67 | { "open": "~E|", "close": "|" }, 68 | { "open": "~F|", "close": "|" }, 69 | { "open": "~H|", "close": "|" }, 70 | { "open": "~L|", "close": "|" }, 71 | { "open": "~N|", "close": "|" }, 72 | { "open": "~R|", "close": "|" }, 73 | { "open": "~S|", "close": "|" }, 74 | { "open": "~T|", "close": "|" }, 75 | { "open": "~U|", "close": "|" }, 76 | { "open": "~W|", "close": "|" }, 77 | { "open": "~b|", "close": "|" }, 78 | { "open": "~c|", "close": "|" }, 79 | { "open": "~e|", "close": "|" }, 80 | { "open": "~r|", "close": "|" }, 81 | { "open": "~s|", "close": "|" }, 82 | { "open": "~w|", "close": "|" }, 83 | 84 | { "open": "~B'", "close": "'" }, 85 | { "open": "~C'", "close": "'" }, 86 | { "open": "~D'", "close": "'" }, 87 | { "open": "~E'", "close": "'" }, 88 | { "open": "~F'", "close": "'" }, 89 | { "open": "~H'", "close": "'" }, 90 | { "open": "~L'", "close": "'" }, 91 | { "open": "~N'", "close": "'" }, 92 | { "open": "~R'", "close": "'" }, 93 | { "open": "~S'", "close": "'" }, 94 | { "open": "~T'", "close": "'" }, 95 | { "open": "~U'", "close": "'" }, 96 | { "open": "~W'", "close": "'" }, 97 | { "open": "~b'", "close": "'" }, 98 | { "open": "~c'", "close": "'" }, 99 | { "open": "~e'", "close": "'" }, 100 | { "open": "~r'", "close": "'" }, 101 | { "open": "~s'", "close": "'" }, 102 | { "open": "~w'", "close": "'" }, 103 | 104 | { "open": "~B<", "close": ">" }, 105 | { "open": "~C<", "close": ">" }, 106 | { "open": "~D<", "close": ">" }, 107 | { "open": "~E<", "close": ">" }, 108 | { "open": "~F<", "close": ">" }, 109 | { "open": "~H<", "close": ">" }, 110 | { "open": "~L<", "close": ">" }, 111 | { "open": "~N<", "close": ">" }, 112 | { "open": "~R<", "close": ">" }, 113 | { "open": "~S<", "close": ">" }, 114 | { "open": "~T<", "close": ">" }, 115 | { "open": "~U<", "close": ">" }, 116 | { "open": "~W<", "close": ">" }, 117 | { "open": "~b<", "close": ">" }, 118 | { "open": "~c<", "close": ">" }, 119 | { "open": "~e<", "close": ">" }, 120 | { "open": "~r<", "close": ">" }, 121 | { "open": "~s<", "close": ">" }, 122 | { "open": "~w<", "close": ">" }, 123 | 124 | { "open": "\"\"\"", "close": "\"\"\"" }, 125 | 126 | { "open": "~B\"\"\"", "close": "\"\"\"" }, 127 | { "open": "~C\"\"\"", "close": "\"\"\"" }, 128 | { "open": "~D\"\"\"", "close": "\"\"\"" }, 129 | { "open": "~E\"\"\"", "close": "\"\"\"" }, 130 | { "open": "~F\"\"\"", "close": "\"\"\"" }, 131 | { "open": "~H\"\"\"", "close": "\"\"\"" }, 132 | { "open": "~L\"\"\"", "close": "\"\"\"" }, 133 | { "open": "~N\"\"\"", "close": "\"\"\"" }, 134 | { "open": "~R\"\"\"", "close": "\"\"\"" }, 135 | { "open": "~S\"\"\"", "close": "\"\"\"" }, 136 | { "open": "~T\"\"\"", "close": "\"\"\"" }, 137 | { "open": "~U\"\"\"", "close": "\"\"\"" }, 138 | { "open": "~W\"\"\"", "close": "\"\"\"" }, 139 | { "open": "~b\"\"\"", "close": "\"\"\"" }, 140 | { "open": "~c\"\"\"", "close": "\"\"\"" }, 141 | { "open": "~e\"\"\"", "close": "\"\"\"" }, 142 | { "open": "~r\"\"\"", "close": "\"\"\"" }, 143 | { "open": "~s\"\"\"", "close": "\"\"\"" }, 144 | { "open": "~w\"\"\"", "close": "\"\"\"" }, 145 | 146 | { "open": "~B'''", "close": "'''" }, 147 | { "open": "~C'''", "close": "'''" }, 148 | { "open": "~D'''", "close": "'''" }, 149 | { "open": "~E'''", "close": "'''" }, 150 | { "open": "~F'''", "close": "'''" }, 151 | { "open": "~H'''", "close": "'''" }, 152 | { "open": "~L'''", "close": "'''" }, 153 | { "open": "~N'''", "close": "'''" }, 154 | { "open": "~R'''", "close": "'''" }, 155 | { "open": "~S'''", "close": "'''" }, 156 | { "open": "~T'''", "close": "'''" }, 157 | { "open": "~U'''", "close": "'''" }, 158 | { "open": "~W'''", "close": "'''" }, 159 | { "open": "~b'''", "close": "'''" }, 160 | { "open": "~c'''", "close": "'''" }, 161 | { "open": "~e'''", "close": "'''" }, 162 | { "open": "~r'''", "close": "'''" }, 163 | { "open": "~s'''", "close": "'''" }, 164 | { "open": "~w'''", "close": "'''" }, 165 | 166 | { "open": "`", "close": "`", "notIn": ["string", "comment"] }, 167 | { "open": "(", "close": ")" }, 168 | { "open": "{", "close": "}" }, 169 | { "open": "[", "close": "]" }, 170 | { "open": "<<", "close": ">>" } 171 | ], 172 | "indentationRules": { 173 | "increaseIndentPattern": "(after|else|catch|rescue|fn|^.*(do|<\\-|\\->|\\{|\\[|\\=))\\s*$", 174 | "decreaseIndentPattern": "^\\s*((\\}|\\])\\s*$|(after|else|catch|rescue|end)\\b)" 175 | }, 176 | "folding": { 177 | "markers": { 178 | "start": "^\\s*(@(moduledoc|typedoc|doc)\\b\\s+(~S)?\"\"\")|(#\\s*region\\b)", 179 | "end": "^\\s+(\"\"\")|(#\\s*endregion\\b)" 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /images/language-server-override.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-lsp/vscode-elixir-ls/90bcea67a49338f2bc875788682661b66caeacbb/images/language-server-override.png -------------------------------------------------------------------------------- /images/logo-fullsize.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-lsp/vscode-elixir-ls/90bcea67a49338f2bc875788682661b66caeacbb/images/logo-fullsize.png -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-lsp/vscode-elixir-ls/90bcea67a49338f2bc875788682661b66caeacbb/images/logo.png -------------------------------------------------------------------------------- /images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-lsp/vscode-elixir-ls/90bcea67a49338f2bc875788682661b66caeacbb/images/screenshot.png -------------------------------------------------------------------------------- /images/test_lens_example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-lsp/vscode-elixir-ls/90bcea67a49338f2bc875788682661b66caeacbb/images/test_lens_example.gif -------------------------------------------------------------------------------- /images/viewing-elixir-ls-output.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-lsp/vscode-elixir-ls/90bcea67a49338f2bc875788682661b66caeacbb/images/viewing-elixir-ls-output.gif -------------------------------------------------------------------------------- /src/commands.ts: -------------------------------------------------------------------------------- 1 | import type * as vscode from "vscode"; 2 | import { configureCopyDebugInfo } from "./commands/copyDebugInfo"; 3 | import { configureExpandMacro } from "./commands/expandMacro"; 4 | import { configureManipulatePipes } from "./commands/manipulatePipes"; 5 | import { configureMixClean } from "./commands/mixClean"; 6 | import { configureRestart } from "./commands/restart"; 7 | import type { LanguageClientManager } from "./languageClientManager"; 8 | 9 | export function configureCommands( 10 | context: vscode.ExtensionContext, 11 | languageClientManager: LanguageClientManager, 12 | ) { 13 | configureCopyDebugInfo(context); 14 | configureExpandMacro(context, languageClientManager); 15 | configureRestart(context, languageClientManager); 16 | configureMixClean(context, languageClientManager, false); 17 | configureMixClean(context, languageClientManager, true); 18 | configureManipulatePipes(context, languageClientManager, "fromPipe"); 19 | configureManipulatePipes(context, languageClientManager, "toPipe"); 20 | } 21 | -------------------------------------------------------------------------------- /src/commands/copyDebugInfo.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "node:child_process"; 2 | import * as os from "node:os"; 3 | import * as vscode from "vscode"; 4 | import { ELIXIR_LS_EXTENSION_NAME } from "../constants"; 5 | 6 | export function configureCopyDebugInfo(context: vscode.ExtensionContext) { 7 | const disposable = vscode.commands.registerCommand( 8 | "extension.copyDebugInfo", 9 | () => { 10 | const elixirVersion = execSync("elixir --version"); 11 | const extension = vscode.extensions.getExtension( 12 | ELIXIR_LS_EXTENSION_NAME, 13 | ); 14 | if (!extension) { 15 | return; 16 | } 17 | 18 | const message = ` 19 | * Elixir & Erlang versions (elixir --version): ${elixirVersion} 20 | * VSCode ElixirLS version: ${extension.packageJSON.version} 21 | * Operating System Version: ${os.platform()} ${os.release()} 22 | `; 23 | 24 | vscode.window.showInformationMessage(`Copied to clipboard: ${message}`); 25 | vscode.env.clipboard.writeText(message); 26 | }, 27 | ); 28 | context.subscriptions.push(disposable); 29 | } 30 | -------------------------------------------------------------------------------- /src/commands/expandMacro.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { 3 | type ExecuteCommandParams, 4 | ExecuteCommandRequest, 5 | State, 6 | } from "vscode-languageclient"; 7 | import { ELIXIR_LS_EXTENSION_NAME } from "../constants"; 8 | import type { LanguageClientManager } from "../languageClientManager"; 9 | 10 | const ExpandMacroTitle = "Expand macro result"; 11 | 12 | function getExpandMacroWebviewContent(content: Record) { 13 | let body = ""; 14 | for (const [key, value] of Object.entries(content)) { 15 | body += `
16 |

${key}

17 |
${value}
18 |
`; 19 | } 20 | 21 | return ` 22 | 23 | 24 | 25 | 26 | ${ExpandMacroTitle} 27 | 28 | 29 | ${body} 30 | 31 | `; 32 | } 33 | 34 | export function configureExpandMacro( 35 | context: vscode.ExtensionContext, 36 | languageClientManager: LanguageClientManager, 37 | ) { 38 | const disposable = vscode.commands.registerCommand( 39 | "extension.expandMacro", 40 | async () => { 41 | const extension = vscode.extensions.getExtension( 42 | ELIXIR_LS_EXTENSION_NAME, 43 | ); 44 | const editor = vscode.window.activeTextEditor; 45 | if (!extension || !editor) { 46 | return; 47 | } 48 | 49 | if (editor.selection.isEmpty) { 50 | console.error("ElixirLS: selection is empty"); 51 | return; 52 | } 53 | 54 | const uri = editor.document.uri; 55 | const clientPromise = languageClientManager.getClientPromiseByDocument( 56 | editor.document, 57 | ); 58 | 59 | if (!clientPromise) { 60 | console.error( 61 | `ElixirLS: no language client for document ${uri.fsPath}`, 62 | ); 63 | return; 64 | } 65 | 66 | const client = await clientPromise; 67 | 68 | if (!client.initializeResult) { 69 | console.error( 70 | `ElixirLS: unable to execute command on server ${ 71 | client.name 72 | } in state ${State[client.state]}`, 73 | ); 74 | return; 75 | } 76 | 77 | const command = 78 | // biome-ignore lint/style/noNonNullAssertion: 79 | client.initializeResult.capabilities.executeCommandProvider?.commands.find( 80 | (c) => c.startsWith("expandMacro:"), 81 | )!; 82 | 83 | const params: ExecuteCommandParams = { 84 | command: command, 85 | arguments: [ 86 | uri.toString(), 87 | editor.document.getText(editor.selection), 88 | editor.selection.start.line, 89 | ], 90 | }; 91 | 92 | const res: Record = await client.sendRequest( 93 | ExecuteCommandRequest.type, 94 | params, 95 | ); 96 | 97 | const panel = vscode.window.createWebviewPanel( 98 | "expandMacro", 99 | ExpandMacroTitle, 100 | vscode.ViewColumn.One, 101 | {}, 102 | ); 103 | panel.webview.html = getExpandMacroWebviewContent(res); 104 | }, 105 | ); 106 | 107 | context.subscriptions.push(disposable); 108 | } 109 | -------------------------------------------------------------------------------- /src/commands/manipulatePipes.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { 3 | type ExecuteCommandParams, 4 | ExecuteCommandRequest, 5 | State, 6 | } from "vscode-languageclient"; 7 | import { ELIXIR_LS_EXTENSION_NAME } from "../constants"; 8 | import type { LanguageClientManager } from "../languageClientManager"; 9 | 10 | export function configureManipulatePipes( 11 | context: vscode.ExtensionContext, 12 | languageClientManager: LanguageClientManager, 13 | operation: "toPipe" | "fromPipe", 14 | ) { 15 | const commandName = `extension.${operation}`; 16 | 17 | const disposable = vscode.commands.registerCommand(commandName, async () => { 18 | const extension = vscode.extensions.getExtension(ELIXIR_LS_EXTENSION_NAME); 19 | const editor = vscode.window.activeTextEditor; 20 | if (!extension || !editor) { 21 | return; 22 | } 23 | 24 | const uri = editor.document.uri; 25 | const clientPromise = languageClientManager.getClientPromiseByDocument( 26 | editor.document, 27 | ); 28 | 29 | if (!clientPromise) { 30 | console.error(`ElixirLS: no language client for document ${uri.fsPath}`); 31 | return; 32 | } 33 | 34 | const client = await clientPromise; 35 | 36 | if (!client.initializeResult) { 37 | console.error( 38 | `ElixirLS: unable to execute command on server ${ 39 | client.name 40 | } in state ${State[client.state]}`, 41 | ); 42 | return; 43 | } 44 | 45 | const command = 46 | // biome-ignore lint/style/noNonNullAssertion: 47 | client.initializeResult.capabilities.executeCommandProvider?.commands.find( 48 | (c: string) => c.startsWith("manipulatePipes:"), 49 | )!; 50 | 51 | const uriStr = uri.toString(); 52 | const args = [ 53 | operation, 54 | uriStr, 55 | editor.selection.start.line, 56 | editor.selection.start.character, 57 | ]; 58 | 59 | const params: ExecuteCommandParams = { command, arguments: args }; 60 | 61 | await client.sendRequest(ExecuteCommandRequest.type, params); 62 | }); 63 | 64 | context.subscriptions.push(disposable); 65 | } 66 | -------------------------------------------------------------------------------- /src/commands/mixClean.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { 3 | type ExecuteCommandParams, 4 | ExecuteCommandRequest, 5 | type LanguageClient, 6 | State, 7 | } from "vscode-languageclient/node"; 8 | import { ELIXIR_LS_EXTENSION_NAME } from "../constants"; 9 | import type { LanguageClientManager } from "../languageClientManager"; 10 | 11 | export function configureMixClean( 12 | context: vscode.ExtensionContext, 13 | languageClientManager: LanguageClientManager, 14 | cleanDeps: boolean, 15 | ) { 16 | const commandName = `extension.${cleanDeps ? "mixCleanIncludeDeps" : "mixClean"}`; 17 | const disposable = vscode.commands.registerCommand(commandName, async () => { 18 | const extension = vscode.extensions.getExtension(ELIXIR_LS_EXTENSION_NAME); 19 | 20 | if (!extension) { 21 | return; 22 | } 23 | 24 | await Promise.all( 25 | languageClientManager 26 | .allClientsPromises() 27 | .map(async (clientPromise: Promise) => { 28 | const client = await clientPromise; 29 | if (!client.initializeResult) { 30 | console.error( 31 | `ElixirLS: unable to execute command on server ${ 32 | client.name 33 | } in state ${State[client.state]}`, 34 | ); 35 | return; 36 | } 37 | const command = 38 | // biome-ignore lint/style/noNonNullAssertion: 39 | client.initializeResult.capabilities.executeCommandProvider?.commands.find( 40 | (c) => c.startsWith("mixClean:"), 41 | )!; 42 | 43 | const params: ExecuteCommandParams = { 44 | command: command, 45 | arguments: [cleanDeps], 46 | }; 47 | 48 | await client.sendRequest(ExecuteCommandRequest.type, params); 49 | }), 50 | ); 51 | }); 52 | 53 | context.subscriptions.push(disposable); 54 | } 55 | -------------------------------------------------------------------------------- /src/commands/restart.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { ELIXIR_LS_EXTENSION_NAME } from "../constants"; 3 | import type { LanguageClientManager } from "../languageClientManager"; 4 | import { reporter } from "../telemetry"; 5 | 6 | export function configureRestart( 7 | context: vscode.ExtensionContext, 8 | languageClientManager: LanguageClientManager, 9 | ) { 10 | const disposable = vscode.commands.registerCommand( 11 | "extension.restart", 12 | async () => { 13 | const extension = vscode.extensions.getExtension( 14 | ELIXIR_LS_EXTENSION_NAME, 15 | ); 16 | 17 | if (!extension) { 18 | return; 19 | } 20 | 21 | reporter.sendTelemetryEvent("restart_command"); 22 | 23 | languageClientManager.restart(); 24 | }, 25 | ); 26 | 27 | context.subscriptions.push(disposable); 28 | } 29 | -------------------------------------------------------------------------------- /src/commands/runTest.ts: -------------------------------------------------------------------------------- 1 | import * as os from "node:os"; 2 | import * as vscode from "vscode"; 3 | import { 4 | type DebuggeeExited, 5 | type DebuggeeOutput, 6 | trackerFactory, 7 | } from "../debugAdapter"; 8 | import { reporter } from "../telemetry"; 9 | 10 | export type RunTestArgs = { 11 | cwd: string; 12 | filePath?: string; 13 | line?: number; 14 | doctestLine?: number; 15 | module?: string; 16 | workspaceFolder: vscode.WorkspaceFolder; 17 | getTest: ( 18 | file: string, 19 | module: string, 20 | describe: string | null, 21 | name: string, 22 | type: string, 23 | ) => vscode.TestItem | undefined; 24 | }; 25 | 26 | // Get the configuration for mix test, if it exists 27 | function getExistingLaunchConfig( 28 | args: RunTestArgs, 29 | debug: boolean, 30 | ): vscode.DebugConfiguration | undefined { 31 | const launchJson = vscode.workspace.getConfiguration( 32 | "launch", 33 | args.workspaceFolder, 34 | ); 35 | const testConfig = launchJson.configurations.findLast( 36 | (e: { name: string }) => e.name === "mix test", 37 | ); 38 | 39 | if (testConfig === undefined) { 40 | return undefined; 41 | } 42 | 43 | // override configuration with sane defaults 44 | testConfig.request = "launch"; 45 | testConfig.task = "test"; 46 | testConfig.projectDir = args.cwd; 47 | testConfig.env = { 48 | MIX_ENV: "test", 49 | ...(testConfig.env ?? {}), 50 | }; 51 | // as of vscode 1.78 ANSI is not fully supported 52 | testConfig.taskArgs = buildTestCommandArgs(args, debug); 53 | testConfig.requireFiles = [ 54 | "test/**/test_helper.exs", 55 | "apps/*/test/**/test_helper.exs", 56 | args.filePath, 57 | ]; 58 | testConfig.noDebug = !debug; 59 | return testConfig; 60 | } 61 | 62 | // Get the config to use for debugging 63 | function getLaunchConfig( 64 | args: RunTestArgs, 65 | debug: boolean, 66 | ): vscode.DebugConfiguration { 67 | const fileConfiguration: vscode.DebugConfiguration | undefined = 68 | getExistingLaunchConfig(args, debug); 69 | 70 | const fallbackConfiguration: vscode.DebugConfiguration = { 71 | type: "mix_task", 72 | name: "mix test", 73 | request: "launch", 74 | task: "test", 75 | env: { 76 | MIX_ENV: "test", 77 | }, 78 | taskArgs: buildTestCommandArgs(args, debug), 79 | startApps: true, 80 | projectDir: args.cwd, 81 | // we need to require all test helpers and only the file we need to test 82 | // mix test runs tests in all required files even if they do not match 83 | // given path:line 84 | requireFiles: [ 85 | "test/**/test_helper.exs", 86 | "apps/*/test/**/test_helper.exs", 87 | args.filePath, 88 | ], 89 | noDebug: !debug, 90 | }; 91 | 92 | const config = fileConfiguration ?? fallbackConfiguration; 93 | 94 | console.log("Starting debug session with launch config", config); 95 | return config; 96 | } 97 | 98 | export async function runTest( 99 | run: vscode.TestRun, 100 | args: RunTestArgs, 101 | debug: boolean, 102 | ): Promise { 103 | reporter.sendTelemetryEvent("run_test", { 104 | "elixir_ls.with_debug": "true", 105 | }); 106 | 107 | const debugConfiguration: vscode.DebugConfiguration = getLaunchConfig( 108 | args, 109 | debug, 110 | ); 111 | 112 | return new Promise((resolve, reject) => { 113 | const listeners: Array = []; 114 | const disposeListeners = () => { 115 | for (const listener of listeners) { 116 | listener.dispose(); 117 | } 118 | }; 119 | let sessionId = ""; 120 | // default to error 121 | // expect DAP `exited` event with mix test exit code 122 | let exitCode = 1; 123 | const output: string[] = []; 124 | listeners.push( 125 | trackerFactory.onOutput((outputEvent: DebuggeeOutput) => { 126 | if (outputEvent.sessionId === sessionId) { 127 | const category = outputEvent.output.body.category; 128 | if (category === "stdout" || category === "stderr") { 129 | output.push(outputEvent.output.body.output); 130 | } else if (category === "ex_unit") { 131 | const exUnitEvent = outputEvent.output.body.data.event; 132 | const data = outputEvent.output.body.data; 133 | const test = args.getTest( 134 | data.file, 135 | data.module, 136 | data.describe, 137 | data.name, 138 | data.type, 139 | ); 140 | if (test) { 141 | if (exUnitEvent === "test_started") { 142 | run.started(test); 143 | } else if (exUnitEvent === "test_passed") { 144 | run.passed(test, data.time / 1000); 145 | } else if (exUnitEvent === "test_failed") { 146 | run.failed( 147 | test, 148 | new vscode.TestMessage(data.message), 149 | data.time / 1000, 150 | ); 151 | } else if (exUnitEvent === "test_errored") { 152 | // ex_unit does not report duration for invalid tests 153 | run.errored(test, new vscode.TestMessage(data.message)); 154 | } else if ( 155 | exUnitEvent === "test_skipped" || 156 | exUnitEvent === "test_excluded" 157 | ) { 158 | run.skipped(test); 159 | } 160 | } else { 161 | if (exUnitEvent !== "test_excluded") { 162 | console.warn( 163 | `ElixirLS: Test ${data.file} ${data.module} ${data.describe} ${data.name} not found`, 164 | ); 165 | } 166 | } 167 | } 168 | } 169 | }), 170 | ); 171 | listeners.push( 172 | trackerFactory.onExited((exit: DebuggeeExited) => { 173 | console.log( 174 | `ElixirLS: Debug session ${exit.sessionId}: debuggee exited with code ${exit.code}`, 175 | ); 176 | if (exit.sessionId === sessionId) { 177 | exitCode = exit.code; 178 | } 179 | }), 180 | ); 181 | listeners.push( 182 | vscode.debug.onDidStartDebugSession((s) => { 183 | console.log(`ElixirLS: Debug session ${s.id} started`); 184 | sessionId = s.id; 185 | }), 186 | ); 187 | listeners.push( 188 | vscode.debug.onDidTerminateDebugSession((s) => { 189 | console.log(`ElixirLS: Debug session ${s.id} terminated`); 190 | 191 | disposeListeners(); 192 | if (exitCode === 0) { 193 | resolve(output.join("")); 194 | } else { 195 | reject(output.join("")); 196 | } 197 | }), 198 | ); 199 | 200 | vscode.debug.startDebugging(args.workspaceFolder, debugConfiguration).then( 201 | (debugSessionStarted) => { 202 | if (!debugSessionStarted) { 203 | reporter.sendTelemetryErrorEvent("run_test_error", { 204 | "elixir_ls.with_debug": "true", 205 | }); 206 | 207 | disposeListeners(); 208 | 209 | reject("Unable to start debug session"); 210 | } 211 | }, 212 | (reason) => { 213 | reporter.sendTelemetryErrorEvent("run_test_error", { 214 | "elixir_ls.with_debug": "true", 215 | "elixir_ls.run_test_error": String(reason), 216 | "elixir_ls.run_test_error_stack": reason?.stack ?? "", 217 | }); 218 | 219 | disposeListeners(); 220 | reject("Unable to start debug session"); 221 | }, 222 | ); 223 | }); 224 | } 225 | 226 | const COMMON_ARGS = ["--formatter", "ElixirLS.DebugAdapter.ExUnitFormatter"]; 227 | 228 | function buildTestCommandArgs(args: RunTestArgs, debug: boolean): string[] { 229 | let line = ""; 230 | if (typeof args.line === "number") { 231 | line = `:${args.line}`; 232 | } 233 | 234 | const result = []; 235 | 236 | if (args.module) { 237 | result.push("--only"); 238 | result.push(`module:${args.module}`); 239 | } 240 | 241 | if (args.doctestLine) { 242 | result.push("--only"); 243 | result.push(`doctest_line:${args.doctestLine}`); 244 | } 245 | 246 | if (args.filePath) { 247 | // workaround for https://github.com/elixir-lang/elixir/issues/13225 248 | // ex_unit file filters with windows path separators are broken on elixir < 1.16.1 249 | // fortunately unix separators work correctly 250 | // TODO remove this when we require elixir 1.17 251 | const path = 252 | os.platform() === "win32" 253 | ? args.filePath.replace("\\", "/") 254 | : args.filePath; 255 | result.push(`${path}${line}`); 256 | } 257 | 258 | // debug tests in tracing mode to disable timeouts 259 | const maybeTrace = debug ? ["--trace"] : []; 260 | 261 | return [...maybeTrace, ...result, ...COMMON_ARGS]; 262 | } 263 | -------------------------------------------------------------------------------- /src/conflictingExtensions.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | function detectConflictingExtension(extensionId: string): void { 4 | const extension = vscode.extensions.getExtension(extensionId); 5 | if (extension) { 6 | vscode.window.showErrorMessage( 7 | `Warning: ${extensionId} is not compatible with ElixirLS, please uninstall ${extensionId}`, 8 | ); 9 | } 10 | } 11 | 12 | export function detectConflictingExtensions() { 13 | detectConflictingExtension("mjmcloug.vscode-elixir"); 14 | // https://github.com/elixir-lsp/vscode-elixir-ls/issues/34 15 | detectConflictingExtension("sammkj.vscode-elixir-formatter"); 16 | } 17 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const RUN_TEST_FROM_CODELENS = "elixir.lens.test.run"; 2 | 3 | export const ELIXIR_LS_EXTENSION_NAME = "jakebecker.elixir-ls"; 4 | -------------------------------------------------------------------------------- /src/debugAdapter.ts: -------------------------------------------------------------------------------- 1 | import type { DebugProtocol } from "@vscode/debugprotocol"; 2 | import * as vscode from "vscode"; 3 | import { buildCommand } from "./executable"; 4 | import { 5 | type TelemetryEvent, 6 | preprocessStacktrace, 7 | preprocessStacktraceInProperties, 8 | reporter, 9 | } from "./telemetry"; 10 | 11 | class DebugAdapterExecutableFactory 12 | implements vscode.DebugAdapterDescriptorFactory 13 | { 14 | private _context: vscode.ExtensionContext; 15 | constructor(context: vscode.ExtensionContext) { 16 | this._context = context; 17 | } 18 | 19 | public createDebugAdapterDescriptor( 20 | session: vscode.DebugSession, 21 | executable: vscode.DebugAdapterExecutable, 22 | ): vscode.ProviderResult { 23 | console.log( 24 | "DebugAdapterExecutableFactory called with session", 25 | session, 26 | "executable", 27 | executable, 28 | ); 29 | const command = buildCommand( 30 | this._context, 31 | "debug_adapter", 32 | session.workspaceFolder, 33 | ); 34 | 35 | const options: vscode.DebugAdapterExecutableOptions = 36 | executable.options ?? {}; 37 | 38 | if (session.workspaceFolder) { 39 | // we starting the session in workspace folder 40 | // set cwd to workspace folder path 41 | options.cwd = session.workspaceFolder.uri.fsPath; 42 | } 43 | 44 | // for folderless session (when `session.workspaceFolder` is `undefined`) 45 | // assume that cwd is workspace root and `projectDir` will be used to point 46 | // to the root of mix project e.g. `"projectDir": "${workspaceRoot:foo}"` 47 | 48 | // for some reason env from launch config is not being passed to executable config 49 | // by default we need to do that manually 50 | if (session.configuration.env) { 51 | options.env = { 52 | ...(options.env ?? {}), 53 | ...session.configuration.env, 54 | }; 55 | } 56 | 57 | const resultExecutable = new vscode.DebugAdapterExecutable( 58 | command, 59 | executable.args, 60 | options, 61 | ); 62 | 63 | if (session.workspaceFolder) { 64 | console.log( 65 | `ElixirLS: starting DAP session in workspace folder ${session.workspaceFolder.name} with executable`, 66 | resultExecutable, 67 | ); 68 | } else { 69 | console.log( 70 | "ElixirLS: starting folderless DAP session with executable", 71 | resultExecutable, 72 | ); 73 | } 74 | 75 | reporter.sendTelemetryEvent("debug_session_starting", { 76 | "elixir_ls.debug_session_mode": session.workspaceFolder 77 | ? "workspaceFolder" 78 | : "folderless", 79 | }); 80 | 81 | return resultExecutable; 82 | } 83 | } 84 | 85 | export interface DebuggeeExited { 86 | sessionId: string; 87 | code: number; 88 | } 89 | 90 | export interface DebuggeeOutput { 91 | sessionId: string; 92 | output: DebugProtocol.OutputEvent; 93 | } 94 | 95 | class DebugAdapterTrackerFactory 96 | implements vscode.DebugAdapterTrackerFactory, vscode.Disposable 97 | { 98 | private _context: vscode.ExtensionContext; 99 | private startTimes: Map = new Map(); 100 | constructor(context: vscode.ExtensionContext) { 101 | this._context = context; 102 | } 103 | 104 | dispose() { 105 | this._onExited.dispose(); 106 | this._onOutput.dispose(); 107 | } 108 | 109 | private _onExited = new vscode.EventEmitter(); 110 | get onExited(): vscode.Event { 111 | return this._onExited.event; 112 | } 113 | 114 | private _onOutput = new vscode.EventEmitter(); 115 | get onOutput(): vscode.Event { 116 | return this._onOutput.event; 117 | } 118 | 119 | public createDebugAdapterTracker( 120 | session: vscode.DebugSession, 121 | ): vscode.ProviderResult { 122 | return { 123 | onWillStartSession: () => { 124 | this.startTimes.set(session.id, performance.now()); 125 | }, 126 | onWillStopSession: () => { 127 | this.startTimes.delete(session.id); 128 | }, 129 | onError: (error: Error) => { 130 | console.warn(`ElixirLS: Debug session ${session.id}: `, error); 131 | 132 | reporter.sendTelemetryErrorEvent("debug_session_error", { 133 | "elixir_ls.debug_session_mode": session.workspaceFolder 134 | ? "workspaceFolder" 135 | : "folderless", 136 | "elixir_ls.debug_session_error": String(error), 137 | "elixir_ls.debug_session_error_stack": error.stack ?? "", 138 | }); 139 | }, 140 | onExit: (code: number | undefined, signal: string | undefined) => { 141 | if (code === 0) { 142 | console.log( 143 | `ElixirLS: Debug session ${session.id}: DAP process exited with code `, 144 | code, 145 | ); 146 | } else { 147 | console.error( 148 | `ElixirLS: Debug session ${session.id}: DAP process exited with code `, 149 | code, 150 | " signal ", 151 | signal, 152 | ); 153 | } 154 | reporter.sendTelemetryErrorEvent("debug_session_exit", { 155 | "elixir_ls.debug_session_mode": session.workspaceFolder 156 | ? "workspaceFolder" 157 | : "folderless", 158 | "elixir_ls.debug_session_exit_code": String(code), 159 | "elixir_ls.debug_session_exit_signal": String(signal), 160 | }); 161 | }, 162 | onDidSendMessage: (message: DebugProtocol.ProtocolMessage) => { 163 | if (message.type === "event") { 164 | const event = message; 165 | if (event.event === "output") { 166 | const outputEvent = message; 167 | if (outputEvent.body.category !== "telemetry") { 168 | this._onOutput.fire({ 169 | sessionId: session.id, 170 | output: outputEvent, 171 | }); 172 | } else { 173 | const telemetryData = outputEvent.body.data; 174 | if (telemetryData.name.endsWith("_error")) { 175 | reporter.sendTelemetryErrorEvent( 176 | telemetryData.name, 177 | { 178 | ...preprocessStacktraceInProperties( 179 | telemetryData.properties, 180 | ), 181 | "elixir_ls.debug_session_mode": session.workspaceFolder 182 | ? "workspaceFolder" 183 | : "folderless", 184 | }, 185 | telemetryData.measurements, 186 | ); 187 | } else { 188 | reporter.sendTelemetryEvent( 189 | telemetryData.name, 190 | { 191 | ...telemetryData.properties, 192 | "elixir_ls.debug_session_mode": session.workspaceFolder 193 | ? "workspaceFolder" 194 | : "folderless", 195 | }, 196 | telemetryData.measurements, 197 | ); 198 | } 199 | } 200 | } 201 | 202 | if (event.event === "initialized") { 203 | const elapsed = 204 | // biome-ignore lint/style/noNonNullAssertion: 205 | performance.now() - this.startTimes.get(session.id)!; 206 | reporter.sendTelemetryEvent( 207 | "debug_session_initialized", 208 | { 209 | "elixir_ls.debug_session_mode": session.workspaceFolder 210 | ? "workspaceFolder" 211 | : "folderless", 212 | }, 213 | { "elixir_ls.debug_session_initialize_time": elapsed }, 214 | ); 215 | } 216 | 217 | if (event.event === "exited") { 218 | const exitedEvent = message; 219 | 220 | reporter.sendTelemetryEvent("debug_session_debuggee_exited", { 221 | "elixir_ls.debug_session_mode": session.workspaceFolder 222 | ? "workspaceFolder" 223 | : "folderless", 224 | "elixir_ls.debug_session_debuggee_exit_code": String( 225 | exitedEvent.body.exitCode, 226 | ), 227 | }); 228 | 229 | this._onExited.fire({ 230 | sessionId: session.id, 231 | code: exitedEvent.body.exitCode, 232 | }); 233 | } 234 | } else if (message.type === "response") { 235 | const response = message; 236 | if (!response.success) { 237 | const errorResponse = message; 238 | if (errorResponse.body.error) { 239 | const errorMessage = errorResponse.body.error; 240 | 241 | if (errorMessage.sendTelemetry) { 242 | // TODO include errorMessage.variables? 243 | reporter.sendTelemetryErrorEvent("dap_request_error", { 244 | "elixir_ls.debug_session_mode": session.workspaceFolder 245 | ? "workspaceFolder" 246 | : "folderless", 247 | "elixir_ls.dap_command": errorResponse.command, 248 | "elixir_ls.dap_error": errorResponse.message, 249 | "elixir_ls.dap_error_message": preprocessStacktrace( 250 | errorMessage.format, 251 | ), 252 | }); 253 | } 254 | } 255 | } 256 | } 257 | }, 258 | }; 259 | } 260 | } 261 | 262 | export let trackerFactory: DebugAdapterTrackerFactory; 263 | 264 | export function configureDebugger(context: vscode.ExtensionContext) { 265 | // Use custom DebugAdapterExecutableFactory that launches the debug adapter with 266 | // the current working directory set to the workspace root so asdf can load 267 | // the correct environment properly. 268 | const factory = new DebugAdapterExecutableFactory(context); 269 | context.subscriptions.push( 270 | vscode.debug.registerDebugAdapterDescriptorFactory("mix_task", factory), 271 | ); 272 | 273 | trackerFactory = new DebugAdapterTrackerFactory(context); 274 | 275 | context.subscriptions.push( 276 | vscode.debug.registerDebugAdapterTrackerFactory("mix_task", trackerFactory), 277 | ); 278 | 279 | context.subscriptions.push(trackerFactory); 280 | } 281 | -------------------------------------------------------------------------------- /src/executable.ts: -------------------------------------------------------------------------------- 1 | import * as os from "node:os"; 2 | import * as path from "node:path"; 3 | import * as vscode from "vscode"; 4 | 5 | const platformCommand = (command: Kind) => 6 | command + (os.platform() === "win32" ? ".bat" : ".sh"); 7 | 8 | export type Kind = "language_server" | "debug_adapter" | "elixir_check"; 9 | export function buildCommand( 10 | context: vscode.ExtensionContext, 11 | kind: Kind, 12 | workspaceFolder: vscode.WorkspaceFolder | undefined, 13 | ) { 14 | // get workspaceFolder scoped configuration or default 15 | const lsOverridePath = vscode.workspace 16 | .getConfiguration("elixirLS", workspaceFolder) 17 | .get("languageServerOverridePath"); 18 | 19 | const command = platformCommand(kind); 20 | 21 | const dir = 22 | process.env.ELS_LOCAL === "1" 23 | ? "./elixir-ls/scripts/" 24 | : "./elixir-ls-release/"; 25 | 26 | return lsOverridePath 27 | ? path.join(lsOverridePath, command) 28 | : context.asAbsolutePath(dir + command); 29 | } 30 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | import { configureCommands } from "./commands"; 4 | import { detectConflictingExtensions } from "./conflictingExtensions"; 5 | import { configureDebugger } from "./debugAdapter"; 6 | import { LanguageClientManager } from "./languageClientManager"; 7 | import { WorkspaceTracker } from "./project"; 8 | import { TaskProvider } from "./taskProvider"; 9 | import { configureTelemetry, reporter } from "./telemetry"; 10 | import { configureTerminalLinkProvider } from "./terminalLinkProvider"; 11 | import { configureTestController } from "./testController"; 12 | import { testElixir } from "./testElixir"; 13 | 14 | console.log("ElixirLS: Loading extension"); 15 | 16 | export interface ElixirLS { 17 | workspaceTracker: WorkspaceTracker; 18 | languageClientManager: LanguageClientManager; 19 | } 20 | 21 | export const workspaceTracker = new WorkspaceTracker(); 22 | export const languageClientManager = new LanguageClientManager( 23 | workspaceTracker, 24 | ); 25 | 26 | const startClientsForOpenDocuments = (context: vscode.ExtensionContext) => { 27 | // biome-ignore lint/complexity/noForEach: 28 | vscode.workspace.textDocuments.forEach((value) => { 29 | languageClientManager.handleDidOpenTextDocument(value, context); 30 | }); 31 | }; 32 | 33 | export function activate(context: vscode.ExtensionContext): ElixirLS { 34 | console.log("ElixirLS: activating extension in mode", workspaceTracker.mode); 35 | console.log( 36 | "ElixirLS: Workspace folders are", 37 | vscode.workspace.workspaceFolders, 38 | ); 39 | console.log( 40 | "ElixirLS: Workspace is", 41 | vscode.workspace.workspaceFile?.toString(), 42 | ); 43 | 44 | configureTelemetry(context); 45 | 46 | reporter.sendTelemetryEvent("extension_activated", { 47 | "elixir_ls.workspace_mode": workspaceTracker.mode, 48 | }); 49 | 50 | context.subscriptions.push( 51 | vscode.workspace.onDidChangeWorkspaceFolders(() => { 52 | console.info( 53 | "ElixirLS: Workspace folders changed", 54 | vscode.workspace.workspaceFolders, 55 | ); 56 | workspaceTracker.handleDidChangeWorkspaceFolders(); 57 | }), 58 | ); 59 | 60 | testElixir(context); 61 | 62 | detectConflictingExtensions(); 63 | 64 | configureCommands(context, languageClientManager); 65 | configureDebugger(context); 66 | configureTerminalLinkProvider(context); 67 | configureTestController(context, languageClientManager, workspaceTracker); 68 | 69 | context.subscriptions.push( 70 | vscode.workspace.onDidOpenTextDocument((value) => { 71 | languageClientManager.handleDidOpenTextDocument(value, context); 72 | }), 73 | ); 74 | 75 | startClientsForOpenDocuments(context); 76 | 77 | context.subscriptions.push( 78 | vscode.workspace.onDidChangeWorkspaceFolders(async (event) => { 79 | for (const folder of event.removed) { 80 | await languageClientManager.handleWorkspaceFolderRemoved(folder); 81 | } 82 | // we might have closed client for some nested workspace folder child 83 | // reopen all needed 84 | startClientsForOpenDocuments(context); 85 | }), 86 | ); 87 | 88 | context.subscriptions.push( 89 | vscode.tasks.registerTaskProvider( 90 | TaskProvider.TaskType, 91 | new TaskProvider(), 92 | ), 93 | ); 94 | 95 | console.log("ElixirLS: extension activated"); 96 | return { 97 | languageClientManager, 98 | workspaceTracker, 99 | }; 100 | } 101 | 102 | export async function deactivate() { 103 | console.log("ElixirLS: deactivating extension"); 104 | reporter.sendTelemetryEvent("extension_deactivated", { 105 | "elixir_ls.workspace_mode": workspaceTracker.mode, 106 | }); 107 | workspaceTracker.handleDidChangeWorkspaceFolders(); 108 | await languageClientManager.deactivate(); 109 | console.log("ElixirLS: extension deactivated"); 110 | } 111 | -------------------------------------------------------------------------------- /src/languageClientManager.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { 3 | type Disposable, 4 | type Executable, 5 | LanguageClient, 6 | type LanguageClientOptions, 7 | RevealOutputChannelOn, 8 | type ServerOptions, 9 | } from "vscode-languageclient/node"; 10 | import { buildCommand } from "./executable"; 11 | import { WorkspaceMode, type WorkspaceTracker } from "./project"; 12 | import { 13 | type TelemetryEvent, 14 | preprocessStacktraceInProperties, 15 | reporter, 16 | } from "./telemetry"; 17 | 18 | // Languages fully handled by this extension 19 | const languageIds = ["elixir", "eex", "html-eex", "phoenix-heex"]; 20 | 21 | // Template languages handled by their own extensions but require activation of this 22 | // one for compiler diagnostics. Template languages that compile down to Elixir AST 23 | // and embed other languages (e.g. HTML, CSS, JS and Elixir itself), should be moved 24 | // here for proper language service forwarding via "embedded-content". 25 | const templateLanguageIds = ["surface"]; 26 | 27 | const activationLanguageIds = languageIds.concat(templateLanguageIds); 28 | 29 | const defaultDocumentSelector = languageIds.flatMap((language) => [ 30 | { language, scheme: "file" }, 31 | { language, scheme: "untitled" }, 32 | { language, scheme: "embedded-content" }, 33 | ]); 34 | 35 | const untitledDocumentSelector = languageIds.map((language) => ({ 36 | language, 37 | scheme: "untitled", 38 | })); 39 | 40 | const patternDocumentSelector = (pattern: string) => 41 | languageIds.map((language) => ({ language, scheme: "file", pattern })); 42 | 43 | // Options to control the language client 44 | const clientOptions: LanguageClientOptions = { 45 | // Register the server for Elixir documents 46 | // the client will iterate through this list and chose the first matching element 47 | documentSelector: defaultDocumentSelector, 48 | // Don't focus the Output pane on errors because request handler errors are no big deal 49 | revealOutputChannelOn: RevealOutputChannelOn.Never, 50 | }; 51 | 52 | function startClient( 53 | context: vscode.ExtensionContext, 54 | clientOptions: LanguageClientOptions, 55 | ): [LanguageClient, Promise, Disposable[]] { 56 | const serverOpts: Executable = { 57 | command: `"${buildCommand( 58 | context, 59 | "language_server", 60 | clientOptions.workspaceFolder, 61 | )}"`, 62 | options: { shell: true }, 63 | }; 64 | 65 | // If the extension is launched in debug mode then the `debug` server options are used instead of `run` 66 | // currently we pass the same options regardless of the mode 67 | const serverOptions: ServerOptions = { 68 | run: serverOpts, 69 | debug: serverOpts, 70 | }; 71 | 72 | // biome-ignore lint/suspicious/noImplicitAnyLet: 73 | let displayName; 74 | if (clientOptions.workspaceFolder) { 75 | console.log( 76 | `ElixirLS: starting LSP client for ${clientOptions.workspaceFolder.uri.fsPath} with server options`, 77 | serverOptions, 78 | "client options", 79 | clientOptions, 80 | ); 81 | displayName = `ElixirLS - ${clientOptions.workspaceFolder?.name}`; 82 | reporter.sendTelemetryEvent("language_client_starting", { 83 | "elixir_ls.language_client_mode": "workspaceFolder", 84 | }); 85 | } else { 86 | console.log( 87 | "ElixirLS: starting default LSP client with server options", 88 | serverOptions, 89 | "client options", 90 | clientOptions, 91 | ); 92 | displayName = "ElixirLS - (default)"; 93 | reporter.sendTelemetryEvent("language_client_starting", { 94 | "elixir_ls.language_client_mode": "default", 95 | }); 96 | } 97 | 98 | const client = new LanguageClient( 99 | "elixirLS", // langId 100 | displayName, // display name 101 | serverOptions, 102 | clientOptions, 103 | ); 104 | 105 | const clientDisposables: Disposable[] = []; 106 | 107 | clientDisposables.push( 108 | client.onTelemetry((event: TelemetryEvent) => { 109 | if (event.name.endsWith("_error")) { 110 | reporter.sendTelemetryErrorEvent( 111 | event.name, 112 | preprocessStacktraceInProperties(event.properties), 113 | event.measurements, 114 | ); 115 | } else { 116 | reporter.sendTelemetryEvent( 117 | event.name, 118 | event.properties, 119 | event.measurements, 120 | ); 121 | } 122 | }), 123 | ); 124 | 125 | const clientPromise = new Promise((resolve, reject) => { 126 | const startTime = performance.now(); 127 | client 128 | .start() 129 | .then(() => { 130 | const elapsed = performance.now() - startTime; 131 | if (clientOptions.workspaceFolder) { 132 | console.log( 133 | `ElixirLS: started LSP client for ${clientOptions.workspaceFolder.uri.toString()}`, 134 | ); 135 | } else { 136 | console.log("ElixirLS: started default LSP client"); 137 | } 138 | reporter.sendTelemetryEvent( 139 | "language_client_started", 140 | { 141 | "elixir_ls.language_client_mode": clientOptions.workspaceFolder 142 | ? "workspaceFolder" 143 | : "default", 144 | }, 145 | { "elixir_ls.language_client_activation_time": elapsed }, 146 | ); 147 | resolve(client); 148 | }) 149 | .catch((reason) => { 150 | reporter.sendTelemetryErrorEvent("language_client_start_error", { 151 | "elixir_ls.language_client_mode": clientOptions.workspaceFolder 152 | ? "workspaceFolder" 153 | : "default", 154 | "elixir_ls.language_client_start_error": String(reason), 155 | "elixir_ls.language_client_start_error_stack": reason?.stack ?? "", 156 | }); 157 | if (clientOptions.workspaceFolder) { 158 | console.error( 159 | `ElixirLS: failed to start LSP client for ${clientOptions.workspaceFolder.uri.toString()}: ${reason}`, 160 | ); 161 | } else { 162 | console.error( 163 | `ElixirLS: failed to start default LSP client: ${reason}`, 164 | ); 165 | } 166 | reject(reason); 167 | }); 168 | }); 169 | 170 | return [client, clientPromise, clientDisposables]; 171 | } 172 | 173 | export class LanguageClientManager { 174 | defaultClient: LanguageClient | null = null; 175 | defaultClientPromise: Promise | null = null; 176 | private defaultClientDisposables: Disposable[] | null = null; 177 | clients: Map = new Map(); 178 | clientsPromises: Map> = new Map(); 179 | private clientsDisposables: Map = new Map(); 180 | private _onDidChange = new vscode.EventEmitter(); 181 | get onDidChange(): vscode.Event { 182 | return this._onDidChange.event; 183 | } 184 | private _workspaceTracker: WorkspaceTracker; 185 | 186 | constructor(workspaceTracker: WorkspaceTracker) { 187 | this._workspaceTracker = workspaceTracker; 188 | } 189 | 190 | public getDefaultClient() { 191 | return this.defaultClient; 192 | } 193 | 194 | public allClients(): LanguageClient[] { 195 | const result = [...this.clients.values()]; 196 | 197 | if (this.defaultClient) { 198 | result.push(this.defaultClient); 199 | } 200 | 201 | return result; 202 | } 203 | 204 | public allClientsPromises(): Promise[] { 205 | const result = [...this.clientsPromises.values()]; 206 | 207 | if (this.defaultClientPromise) { 208 | result.push(this.defaultClientPromise); 209 | } 210 | 211 | return result; 212 | } 213 | 214 | public restart() { 215 | const restartPromise = async ( 216 | client: LanguageClient, 217 | isDefault: boolean, 218 | key?: string | undefined, 219 | ) => 220 | new Promise((resolve, reject) => { 221 | reporter.sendTelemetryEvent("language_client_restarting", { 222 | "elixir_ls.language_client_mode": !isDefault 223 | ? "workspaceFolder" 224 | : "default", 225 | }); 226 | const startTime = performance.now(); 227 | client 228 | .restart() 229 | .then(() => { 230 | const elapsed = performance.now() - startTime; 231 | reporter.sendTelemetryEvent( 232 | "language_client_started", 233 | { 234 | "elixir_ls.language_client_mode": !isDefault 235 | ? "workspaceFolder" 236 | : "default", 237 | }, 238 | { "elixir_ls.language_client_activation_time": elapsed }, 239 | ); 240 | if (!isDefault) { 241 | console.log(`ElixirLS: started LSP client for ${key}`); 242 | } else { 243 | console.log("ElixirLS: started default LSP client"); 244 | } 245 | resolve(client); 246 | }) 247 | .catch((e) => { 248 | reporter.sendTelemetryErrorEvent("language_client_restart_error", { 249 | "elixir_ls.language_client_mode": !isDefault 250 | ? "workspaceFolder" 251 | : "default", 252 | "elixir_ls.language_client_start_error": String(e), 253 | "elixir_ls.language_client_start_error_stack": e?.stack ?? "", 254 | }); 255 | if (!isDefault) { 256 | console.error( 257 | `ElixirLS: failed to start LSP client for ${key}: ${e}`, 258 | ); 259 | } else { 260 | console.error( 261 | `ElixirLS: failed to start default LSP client: ${e}`, 262 | ); 263 | } 264 | reject(e); 265 | }); 266 | }); 267 | 268 | for (const [key, client] of this.clients) { 269 | console.log(`ElixirLS: restarting LSP client for ${key}`); 270 | this.clientsPromises.set(key, restartPromise(client, false, key)); 271 | } 272 | if (this.defaultClient) { 273 | console.log("ElixirLS: restarting default LSP client"); 274 | this.defaultClientPromise = restartPromise(this.defaultClient, true); 275 | } 276 | } 277 | 278 | public getClientByUri(uri: vscode.Uri): LanguageClient { 279 | // Files outside of workspace go to default client when no directory is open 280 | // otherwise they go to first workspace 281 | // (even if we pass undefined in clientOptions vs will pass first workspace as rootUri/rootPath) 282 | let folder = vscode.workspace.getWorkspaceFolder(uri); 283 | if (!folder) { 284 | if ( 285 | vscode.workspace.workspaceFolders && 286 | vscode.workspace.workspaceFolders.length !== 0 287 | ) { 288 | // untitled: and file: outside workspace folders assigned to first workspace 289 | folder = vscode.workspace.workspaceFolders[0]; 290 | } else { 291 | // no workspace folders - use default client 292 | if (this.defaultClient) { 293 | return this.defaultClient; 294 | } 295 | throw "default client LSP not started"; 296 | } 297 | } 298 | 299 | // If we have nested workspace folders we only start a server on the outer most workspace folder. 300 | folder = this._workspaceTracker.getOuterMostWorkspaceFolder(folder); 301 | 302 | const client = this.clients.get(folder.uri.toString()); 303 | if (client) { 304 | return client; 305 | } 306 | throw `LSP client for ${folder.uri.toString()} not started`; 307 | } 308 | 309 | public getClientPromiseByUri(uri: vscode.Uri): Promise { 310 | // Files outside of workspace go to default client when no directory is open 311 | // otherwise they go to first workspace 312 | // (even if we pass undefined in clientOptions vs will pass first workspace as rootUri/rootPath) 313 | let folder = vscode.workspace.getWorkspaceFolder(uri); 314 | if (!folder) { 315 | if ( 316 | vscode.workspace.workspaceFolders && 317 | vscode.workspace.workspaceFolders.length !== 0 318 | ) { 319 | // untitled: and file: outside workspace folders assigned to first workspace 320 | folder = vscode.workspace.workspaceFolders[0]; 321 | } else { 322 | // no folders - use default client 323 | // biome-ignore lint/style/noNonNullAssertion: 324 | return this.defaultClientPromise!; 325 | } 326 | } 327 | 328 | // If we have nested workspace folders we only start a server on the outer most workspace folder. 329 | folder = this._workspaceTracker.getOuterMostWorkspaceFolder(folder); 330 | 331 | // biome-ignore lint/style/noNonNullAssertion: 332 | return this.clientsPromises.get(folder.uri.toString())!; 333 | } 334 | 335 | public getClientByDocument( 336 | document: vscode.TextDocument, 337 | ): LanguageClient | null { 338 | // We are only interested in elixir files 339 | if (document.languageId !== "elixir") { 340 | return null; 341 | } 342 | 343 | return this.getClientByUri(document.uri); 344 | } 345 | 346 | public getClientPromiseByDocument( 347 | document: vscode.TextDocument, 348 | ): Promise | null { 349 | // We are only interested in elixir files 350 | if (document.languageId !== "elixir") { 351 | return null; 352 | } 353 | 354 | return this.getClientPromiseByUri(document.uri); 355 | } 356 | 357 | public handleDidOpenTextDocument( 358 | document: vscode.TextDocument, 359 | context: vscode.ExtensionContext, 360 | ) { 361 | // We are only interested in elixir related files 362 | if (!activationLanguageIds.includes(document.languageId)) { 363 | return; 364 | } 365 | 366 | const uri = document.uri; 367 | let folder = vscode.workspace.getWorkspaceFolder(uri); 368 | 369 | // Files outside of workspace go to default client when no workspace folder is open 370 | // otherwise they go to first workspace 371 | // NOTE 372 | // even if we pass undefined in clientOptions and try to create a default client 373 | // vscode will pass first workspace as rootUri/rootPath and we will have 2 servers 374 | // running in the same directory 375 | if (!folder) { 376 | if ( 377 | vscode.workspace.workspaceFolders && 378 | vscode.workspace.workspaceFolders.length !== 0 379 | ) { 380 | // untitled: or file: outside the workspace folders assigned to first workspace 381 | folder = vscode.workspace.workspaceFolders[0]; 382 | } else { 383 | // no workspace - use default client 384 | if (!this.defaultClient) { 385 | // Create the language client and start the client 386 | // the client will get all requests from untitled: file: 387 | [ 388 | this.defaultClient, 389 | this.defaultClientPromise, 390 | this.defaultClientDisposables, 391 | ] = startClient(context, clientOptions); 392 | this._onDidChange.fire(); 393 | } 394 | return; 395 | } 396 | } 397 | 398 | // If we have nested workspace folders we only start a server on the outer most workspace folder. 399 | folder = this._workspaceTracker.getOuterMostWorkspaceFolder(folder); 400 | 401 | if (!this.clients.has(folder.uri.toString())) { 402 | // biome-ignore lint/suspicious/noImplicitAnyLet: 403 | let documentSelector; 404 | if (this._workspaceTracker.mode === WorkspaceMode.MULTI_ROOT) { 405 | // multi-root workspace 406 | // create document selector with glob pattern that will match files 407 | // in that directory 408 | const pattern = `${folder.uri.fsPath}/**/*`; 409 | // additionally if this is the first workspace add untitled schema files 410 | // NOTE that no client will match file: outside any of the workspace folders 411 | // if we passed a glob allowing any file the first server would get requests form 412 | // other workspace folders 413 | const maybeUntitledDocumentSelector = 414 | folder.index === 0 ? untitledDocumentSelector : []; 415 | 416 | documentSelector = [ 417 | ...patternDocumentSelector(pattern), 418 | ...maybeUntitledDocumentSelector, 419 | ]; 420 | } else if (this._workspaceTracker.mode === WorkspaceMode.SINGLE_FOLDER) { 421 | // single folder workspace 422 | // no need to filter with glob patterns 423 | // the client will get all requests even from untitled: and files outside 424 | // workspace folder 425 | documentSelector = defaultDocumentSelector; 426 | } else if (this._workspaceTracker.mode === WorkspaceMode.NO_WORKSPACE) { 427 | throw "this should not happen"; 428 | } 429 | 430 | const workspaceClientOptions: LanguageClientOptions = { 431 | ...clientOptions, 432 | // the client will iterate through this list and chose the first matching element 433 | documentSelector: documentSelector, 434 | workspaceFolder: folder, 435 | }; 436 | 437 | const [client, clientPromise, clientDisposables] = startClient( 438 | context, 439 | workspaceClientOptions, 440 | ); 441 | this.clients.set(folder.uri.toString(), client); 442 | this.clientsPromises.set(folder.uri.toString(), clientPromise); 443 | this.clientsDisposables.set(folder.uri.toString(), clientDisposables); 444 | this._onDidChange.fire(); 445 | } 446 | } 447 | 448 | public async deactivate() { 449 | const clientStartPromises: Promise[] = []; 450 | const clientsToDispose: LanguageClient[] = []; 451 | let changed = false; 452 | if (this.defaultClient) { 453 | // biome-ignore lint/complexity/noForEach: 454 | this.defaultClientDisposables?.forEach((d) => d.dispose()); 455 | // biome-ignore lint/style/noNonNullAssertion: 456 | clientStartPromises.push(this.defaultClientPromise!); 457 | clientsToDispose.push(this.defaultClient); 458 | this.defaultClient = null; 459 | this.defaultClientPromise = null; 460 | this.defaultClientDisposables = null; 461 | changed = true; 462 | } 463 | 464 | for (const [uri, client] of this.clients.entries()) { 465 | // biome-ignore lint/complexity/noForEach: 466 | this.clientsDisposables.get(uri)?.forEach((d) => d.dispose()); 467 | // biome-ignore lint/style/noNonNullAssertion: 468 | clientStartPromises.push(this.clientsPromises.get(uri)!); 469 | clientsToDispose.push(client); 470 | changed = true; 471 | } 472 | 473 | this.clients.clear(); 474 | this.clientsPromises.clear(); 475 | this.clientsDisposables.clear(); 476 | 477 | if (changed) { 478 | this._onDidChange.fire(); 479 | } 480 | // need to await - disposing or stopping a starting client crashes 481 | // in vscode-languageclient 8.1.0 482 | // https://github.com/microsoft/vscode-languageserver-node/blob/d859bb14d1bcb3923eecaf0ef587e55c48502ccc/client/src/common/client.ts#L1311 483 | try { 484 | await Promise.all(clientStartPromises); 485 | } catch { 486 | /* no reason to log here */ 487 | } 488 | try { 489 | // dispose can timeout 490 | await Promise.all(clientsToDispose.map((client) => client.dispose())); 491 | } catch { 492 | /* no reason to log here */ 493 | } 494 | } 495 | 496 | public async handleWorkspaceFolderRemoved(folder: vscode.WorkspaceFolder) { 497 | const uri = folder.uri.toString(); 498 | const client = this.clients.get(uri); 499 | if (client) { 500 | console.log("ElixirLS: Stopping LSP client for", folder.uri.fsPath); 501 | // biome-ignore lint/complexity/noForEach: 502 | this.clientsDisposables.get(uri)?.forEach((d) => d.dispose()); 503 | // biome-ignore lint/style/noNonNullAssertion: 504 | const clientPromise = this.clientsPromises.get(uri)!; 505 | 506 | this.clients.delete(uri); 507 | this.clientsPromises.delete(uri); 508 | 509 | this._onDidChange.fire(); 510 | 511 | // need to await - disposing or stopping a starting client crashes 512 | // in vscode-languageclient 8.1.0 513 | // https://github.com/microsoft/vscode-languageserver-node/blob/d859bb14d1bcb3923eecaf0ef587e55c48502ccc/client/src/common/client.ts#L1311 514 | try { 515 | await clientPromise; 516 | } catch (e) { 517 | console.warn( 518 | "ElixirLS: error during wait for stoppable LSP client state", 519 | e, 520 | ); 521 | reporter.sendTelemetryErrorEvent("language_client_stop_error", { 522 | "elixir_ls.language_client_stop_error": String(e), 523 | // biome-ignore lint/suspicious/noExplicitAny: 524 | "elixir_ls.language_client_start_error_stack": (e)?.stack ?? "", 525 | }); 526 | } 527 | try { 528 | // dispose can timeout 529 | await client.dispose(); 530 | } catch (e) { 531 | console.warn("ElixirLS: error during LSP client dispose", e); 532 | reporter.sendTelemetryErrorEvent("language_client_stop_error", { 533 | "elixir_ls.language_client_stop_error": String(e), 534 | // biome-ignore lint/suspicious/noExplicitAny: 535 | "elixir_ls.language_client_start_error_stack": (e)?.stack ?? "", 536 | }); 537 | } 538 | } 539 | } 540 | } 541 | -------------------------------------------------------------------------------- /src/project.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "node:fs"; 2 | import * as path from "node:path"; 3 | import * as vscode from "vscode"; 4 | 5 | export function getProjectDir(workspaceFolder: vscode.WorkspaceFolder): string { 6 | // check if projectDir is not overridden in workspace 7 | const projectDir = vscode.workspace 8 | .getConfiguration("elixirLS", workspaceFolder) 9 | .get("projectDir"); 10 | 11 | return projectDir 12 | ? path.join(workspaceFolder.uri.fsPath, projectDir) 13 | : workspaceFolder.uri.fsPath; 14 | } 15 | 16 | export enum WorkspaceMode { 17 | NO_WORKSPACE = "NO_WORKSPACE", 18 | SINGLE_FOLDER = "SINGLE_FOLDER", 19 | MULTI_ROOT = "MULTI_ROOT", 20 | } 21 | 22 | export class WorkspaceTracker { 23 | private _sortedWorkspaceFolders: string[] | undefined; 24 | 25 | private sortedWorkspaceFolders(): string[] { 26 | if (this._sortedWorkspaceFolders === void 0) { 27 | this._sortedWorkspaceFolders = vscode.workspace.workspaceFolders 28 | ? vscode.workspace.workspaceFolders 29 | .map((folder) => { 30 | let result = folder.uri.toString(); 31 | if (result.charAt(result.length - 1) !== "/") { 32 | result = `${result}/`; 33 | } 34 | return result; 35 | }) 36 | .sort((a, b) => { 37 | return a.length - b.length; 38 | }) 39 | : []; 40 | } 41 | return this._sortedWorkspaceFolders; 42 | } 43 | 44 | public getOuterMostWorkspaceFolder( 45 | folder: vscode.WorkspaceFolder, 46 | ): vscode.WorkspaceFolder { 47 | return this._getOuterMostWorkspaceFolder(folder, false); 48 | } 49 | 50 | private _getOuterMostWorkspaceFolder( 51 | folder: vscode.WorkspaceFolder, 52 | isRetry: boolean, 53 | ): vscode.WorkspaceFolder { 54 | let uri = folder.uri.toString(); 55 | if (uri.charAt(uri.length - 1) !== "/") { 56 | uri = `${uri}/`; 57 | } 58 | 59 | const useCurrentRootFolderAsProjectDir = vscode.workspace 60 | .getConfiguration("elixirLS", folder) 61 | .get("useCurrentRootFolderAsProjectDir"); 62 | 63 | let outermostFolder: vscode.WorkspaceFolder | null = null; 64 | 65 | const sortedWorkspaceFolders = useCurrentRootFolderAsProjectDir 66 | ? [uri] 67 | : this.sortedWorkspaceFolders(); 68 | 69 | for (const element of sortedWorkspaceFolders) { 70 | if (uri.startsWith(element)) { 71 | const foundFolder = vscode.workspace.getWorkspaceFolder( 72 | vscode.Uri.parse(element), 73 | ); 74 | 75 | if (foundFolder) { 76 | if (!outermostFolder) { 77 | // store outermost no mix.exs candidate 78 | // it will be discarded if better one with mix.exs is found 79 | outermostFolder = foundFolder; 80 | } 81 | 82 | const projectDir = getProjectDir(foundFolder); 83 | const mixFilePath = path.join(projectDir, "mix.exs"); 84 | if (fs.existsSync(mixFilePath)) { 85 | // outermost workspace folder with mix.exs found 86 | return foundFolder; 87 | } 88 | } 89 | } 90 | } 91 | 92 | if (outermostFolder) { 93 | // no folder containing mix.exs was found, return the outermost folder 94 | return outermostFolder; 95 | } 96 | 97 | // most likely handleDidChangeWorkspaceFolders callback hs not yet run 98 | // clear cache and try again 99 | if (!isRetry) { 100 | this.handleDidChangeWorkspaceFolders(); 101 | return this._getOuterMostWorkspaceFolder(folder, true); 102 | } 103 | throw `not able to find outermost workspace folder for ${folder.uri.fsPath}`; 104 | } 105 | 106 | public handleDidChangeWorkspaceFolders() { 107 | this._sortedWorkspaceFolders = undefined; 108 | } 109 | 110 | public getProjectDirForUri(uri: vscode.Uri) { 111 | const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri); 112 | if (workspaceFolder) { 113 | const outermostWorkspaceFolder = 114 | this.getOuterMostWorkspaceFolder(workspaceFolder); 115 | return getProjectDir(outermostWorkspaceFolder); 116 | } 117 | } 118 | 119 | public get mode(): WorkspaceMode { 120 | if (vscode.workspace.workspaceFile) { 121 | return WorkspaceMode.MULTI_ROOT; 122 | } 123 | if ( 124 | vscode.workspace.workspaceFolders && 125 | vscode.workspace.workspaceFolders.length !== 0 126 | ) { 127 | return WorkspaceMode.SINGLE_FOLDER; 128 | } 129 | return WorkspaceMode.NO_WORKSPACE; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/taskProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | export class TaskProvider implements vscode.TaskProvider { 4 | // Referenced in package.json::taskDefinitions 5 | static TaskType = "mix"; 6 | 7 | public provideTasks(): vscode.Task[] { 8 | const wsFolders = vscode.workspace.workspaceFolders; 9 | if (!wsFolders || !wsFolders[0]) { 10 | vscode.window.showErrorMessage("no workspace open..."); 11 | return []; 12 | } 13 | 14 | // TODO make sure that problem matchers are working 15 | // TODO better handle multi root workspaces 16 | 17 | const tasks = []; 18 | 19 | const test = new vscode.Task( 20 | { type: TaskProvider.TaskType, task: "Run tests" }, 21 | wsFolders[0], 22 | "Run tests", 23 | TaskProvider.TaskType, 24 | new vscode.ShellExecution("mix test"), 25 | ["$mixCompileError", "$mixCompileWarning", "$mixTestFailure"], 26 | ); 27 | 28 | test.group = vscode.TaskGroup.Test; 29 | 30 | tasks.push(test); 31 | 32 | const testCoverage = new vscode.Task( 33 | { type: TaskProvider.TaskType, task: "Run tests with coverage" }, 34 | wsFolders[0], 35 | "Run tests with coverage", 36 | TaskProvider.TaskType, 37 | new vscode.ShellExecution("mix test.coverage"), 38 | ["$mixCompileError", "$mixCompileWarning", "$mixTestFailure"], 39 | ); 40 | 41 | testCoverage.group = vscode.TaskGroup.Test; 42 | 43 | tasks.push(testCoverage); 44 | 45 | const testUnderCursorTask = new vscode.Task( 46 | { type: TaskProvider.TaskType, task: "Run test at cursor" }, 47 | wsFolders[0], 48 | "Run test at cursor", 49 | TaskProvider.TaskType, 50 | new vscode.ShellExecution("mix test ${relativeFile}:${lineNumber}"), 51 | ["$mixCompileError", "$mixCompileWarning", "$mixTestFailure"], 52 | ); 53 | 54 | testUnderCursorTask.group = vscode.TaskGroup.Test; 55 | 56 | tasks.push(testUnderCursorTask); 57 | 58 | const testsInFileTask = new vscode.Task( 59 | { type: TaskProvider.TaskType, task: "Run tests in current file" }, 60 | wsFolders[0], 61 | "Run tests in current file", 62 | TaskProvider.TaskType, 63 | new vscode.ShellExecution("mix test ${relativeFile}"), 64 | ["$mixCompileError", "$mixCompileWarning", "$mixTestFailure"], 65 | ); 66 | 67 | testsInFileTask.group = vscode.TaskGroup.Test; 68 | 69 | tasks.push(testsInFileTask); 70 | 71 | const compile = new vscode.Task( 72 | { type: TaskProvider.TaskType, task: "Build" }, 73 | wsFolders[0], 74 | "Build", 75 | TaskProvider.TaskType, 76 | new vscode.ShellExecution("mix compile"), 77 | ["$mixCompileError", "$mixCompileWarning"], 78 | ); 79 | 80 | compile.group = vscode.TaskGroup.Build; 81 | 82 | tasks.push(compile); 83 | 84 | const depsCompile = new vscode.Task( 85 | { type: TaskProvider.TaskType, task: "Build dependencies" }, 86 | wsFolders[0], 87 | "Build dependencies", 88 | TaskProvider.TaskType, 89 | new vscode.ShellExecution("mix deps.compile"), 90 | ["$mixCompileError", "$mixCompileWarning"], 91 | ); 92 | 93 | depsCompile.group = vscode.TaskGroup.Build; 94 | 95 | tasks.push(depsCompile); 96 | 97 | const clean = new vscode.Task( 98 | { type: TaskProvider.TaskType, task: "Clean project" }, 99 | wsFolders[0], 100 | "Clean project", 101 | TaskProvider.TaskType, 102 | new vscode.ShellExecution("mix clean"), 103 | ); 104 | 105 | clean.group = vscode.TaskGroup.Clean; 106 | 107 | tasks.push(clean); 108 | 109 | const cleanWithDeps = new vscode.Task( 110 | { type: TaskProvider.TaskType, task: "Clean project and deps" }, 111 | wsFolders[0], 112 | "Clean project and deps", 113 | TaskProvider.TaskType, 114 | new vscode.ShellExecution("mix clean --deps"), 115 | ); 116 | 117 | cleanWithDeps.group = vscode.TaskGroup.Clean; 118 | 119 | tasks.push(cleanWithDeps); 120 | 121 | const appTree = new vscode.Task( 122 | { type: TaskProvider.TaskType, task: "Print app tree" }, 123 | wsFolders[0], 124 | "Print app tree", 125 | TaskProvider.TaskType, 126 | new vscode.ShellExecution("mix app.tree"), 127 | ); 128 | 129 | tasks.push(appTree); 130 | 131 | const deps = new vscode.Task( 132 | { type: TaskProvider.TaskType, task: "List deps" }, 133 | wsFolders[0], 134 | "List deps", 135 | TaskProvider.TaskType, 136 | new vscode.ShellExecution("mix deps"), 137 | ); 138 | 139 | tasks.push(deps); 140 | 141 | const depsCleanAll = new vscode.Task( 142 | { type: TaskProvider.TaskType, task: "Clean all deps" }, 143 | wsFolders[0], 144 | "Clean all deps", 145 | TaskProvider.TaskType, 146 | new vscode.ShellExecution("mix deps.clean --all"), 147 | ); 148 | 149 | depsCleanAll.group = vscode.TaskGroup.Clean; 150 | 151 | tasks.push(depsCleanAll); 152 | 153 | const depsCleanUnused = new vscode.Task( 154 | { type: TaskProvider.TaskType, task: "Clean all unused deps" }, 155 | wsFolders[0], 156 | "Clean all unused deps", 157 | TaskProvider.TaskType, 158 | new vscode.ShellExecution("mix deps.clean --unlock --unused"), 159 | ); 160 | 161 | depsCleanUnused.group = vscode.TaskGroup.Clean; 162 | 163 | tasks.push(depsCleanUnused); 164 | 165 | const depsGet = new vscode.Task( 166 | { type: TaskProvider.TaskType, task: "Get deps" }, 167 | wsFolders[0], 168 | "Get deps", 169 | TaskProvider.TaskType, 170 | new vscode.ShellExecution("mix deps.get"), 171 | ); 172 | 173 | tasks.push(depsGet); 174 | 175 | const depsUpdateAll = new vscode.Task( 176 | { type: TaskProvider.TaskType, task: "Update all deps" }, 177 | wsFolders[0], 178 | "Update all deps", 179 | TaskProvider.TaskType, 180 | new vscode.ShellExecution("mix deps.update --all"), 181 | ); 182 | 183 | tasks.push(depsUpdateAll); 184 | 185 | const format = new vscode.Task( 186 | { type: TaskProvider.TaskType, task: "Format" }, 187 | wsFolders[0], 188 | "Format", 189 | TaskProvider.TaskType, 190 | new vscode.ShellExecution("mix format"), 191 | ); 192 | 193 | tasks.push(format); 194 | 195 | const run = new vscode.Task( 196 | { type: TaskProvider.TaskType, task: "Run" }, 197 | wsFolders[0], 198 | "Run", 199 | TaskProvider.TaskType, 200 | new vscode.ShellExecution("mix run"), 201 | ); 202 | 203 | tasks.push(run); 204 | 205 | const runNoHalt = new vscode.Task( 206 | { type: TaskProvider.TaskType, task: "Run no halt" }, 207 | wsFolders[0], 208 | "Run no halt", 209 | TaskProvider.TaskType, 210 | new vscode.ShellExecution("mix run --no-halt"), 211 | ); 212 | 213 | tasks.push(runNoHalt); 214 | 215 | const releaseInit = new vscode.Task( 216 | { 217 | type: TaskProvider.TaskType, 218 | task: "Generates sample files for releases", 219 | }, 220 | wsFolders[0], 221 | "Generates sample files for releases", 222 | TaskProvider.TaskType, 223 | new vscode.ShellExecution("mix release.init"), 224 | ); 225 | 226 | tasks.push(releaseInit); 227 | 228 | const xrefTraceFile = new vscode.Task( 229 | { type: TaskProvider.TaskType, task: "Trace file dependencies" }, 230 | wsFolders[0], 231 | "Trace file dependencies", 232 | TaskProvider.TaskType, 233 | new vscode.ShellExecution("mix xref trace ${relativeFile}"), 234 | ); 235 | 236 | tasks.push(xrefTraceFile); 237 | 238 | const xrefGraph = new vscode.Task( 239 | { type: TaskProvider.TaskType, task: "Print file dependency graph" }, 240 | wsFolders[0], 241 | "Print file dependency graph", 242 | TaskProvider.TaskType, 243 | new vscode.ShellExecution("mix xref graph"), 244 | ); 245 | 246 | tasks.push(xrefGraph); 247 | 248 | return tasks; 249 | } 250 | 251 | public resolveTask(_task: vscode.Task): vscode.Task | undefined { 252 | // This method can be implemented to improve performance. 253 | // See: https://code.visualstudio.com/api/extension-guides/task-provider 254 | return undefined; 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /src/telemetry.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type TelemetryEventMeasurements, 3 | type TelemetryEventProperties, 4 | TelemetryReporter, 5 | } from "@vscode/extension-telemetry"; 6 | import type * as vscode from "vscode"; 7 | 8 | const key = 9 | "InstrumentationKey=0979629c-3be4-4b0d-93f2-2be81cccd799;IngestionEndpoint=https://eastus-8.in.applicationinsights.azure.com/;LiveEndpoint=https://eastus.livediagnostics.monitor.azure.com/;ApplicationId=7ad820f6-e2a9-4df6-97d8-d72ce90b8460"; 10 | const fakeKey = "00000000-0000-0000-0000-000000000000"; 11 | 12 | interface EventSamplingConfig { 13 | eventName: string; 14 | propertyKey?: string; 15 | propertyValue?: string; 16 | samplingFactor: number; // Range 0-1 17 | } 18 | 19 | const samplingConfigs: EventSamplingConfig[] = [ 20 | { 21 | eventName: "build", 22 | samplingFactor: 0.004, 23 | }, 24 | { 25 | eventName: "build_error", 26 | samplingFactor: 0.1, 27 | }, 28 | { 29 | eventName: "test_controller_resolve_error", 30 | samplingFactor: 0.01, 31 | }, 32 | { 33 | eventName: "dialyzer", 34 | samplingFactor: 0.04, 35 | }, 36 | { 37 | eventName: "lsp_request", 38 | propertyKey: "elixir_ls.lsp_command", 39 | propertyValue: "textDocument_foldingRange", 40 | samplingFactor: 0.0002, 41 | }, 42 | { 43 | eventName: "lsp_request", 44 | propertyKey: "elixir_ls.lsp_command", 45 | propertyValue: "textDocument_completion", 46 | samplingFactor: 0.002, 47 | }, 48 | { 49 | eventName: "lsp_request", 50 | propertyKey: "elixir_ls.lsp_command", 51 | propertyValue: "textDocument_codeAction", 52 | samplingFactor: 0.0002, 53 | }, 54 | { 55 | eventName: "lsp_request", 56 | propertyKey: "elixir_ls.lsp_command", 57 | propertyValue: "textDocument_hover", 58 | samplingFactor: 0.002, 59 | }, 60 | { 61 | eventName: "lsp_request", 62 | propertyKey: "elixir_ls.lsp_command", 63 | propertyValue: "textDocument_documentSymbol", 64 | samplingFactor: 0.0005, 65 | }, 66 | { 67 | eventName: "lsp_request", 68 | propertyKey: "elixir_ls.lsp_command", 69 | propertyValue: "workspace_symbol", 70 | samplingFactor: 0.02, 71 | }, 72 | { 73 | eventName: "lsp_request", 74 | propertyKey: "elixir_ls.lsp_command", 75 | propertyValue: "textDocument_codeLens", 76 | samplingFactor: 0.003, 77 | }, 78 | { 79 | eventName: "lsp_request", 80 | propertyKey: "elixir_ls.lsp_command", 81 | propertyValue: "workspace_executeCommand:getExUnitTestsInFile", 82 | samplingFactor: 0.003, 83 | }, 84 | { 85 | eventName: "lsp_request", 86 | propertyKey: "elixir_ls.lsp_command", 87 | propertyValue: "textDocument_signatureHelp", 88 | samplingFactor: 0.004, 89 | }, 90 | { 91 | eventName: "lsp_request", 92 | propertyKey: "elixir_ls.lsp_command", 93 | propertyValue: "textDocument_definition", 94 | samplingFactor: 0.004, 95 | }, 96 | { 97 | eventName: "lsp_request", 98 | propertyKey: "elixir_ls.lsp_command", 99 | propertyValue: "textDocument_onTypeFormatting", 100 | samplingFactor: 0.006, 101 | }, 102 | { 103 | eventName: "lsp_request", 104 | propertyKey: "elixir_ls.lsp_command", 105 | propertyValue: "textDocument_formatting", 106 | samplingFactor: 0.008, 107 | }, 108 | { 109 | eventName: "lsp_request", 110 | propertyKey: "elixir_ls.lsp_command", 111 | propertyValue: "initialize", 112 | samplingFactor: 0.03, 113 | }, 114 | { 115 | eventName: "dap_request", 116 | propertyKey: "elixir_ls.dap_command", 117 | propertyValue: "threads", 118 | samplingFactor: 0.05, 119 | }, 120 | { 121 | eventName: "dap_request", 122 | propertyKey: "elixir_ls.dap_command", 123 | propertyValue: "initialize", 124 | samplingFactor: 0.2, 125 | }, 126 | { 127 | eventName: "dap_request", 128 | propertyKey: "elixir_ls.dap_command", 129 | propertyValue: "launch", 130 | samplingFactor: 0.2, 131 | }, 132 | { 133 | eventName: "dap_request", 134 | propertyKey: "elixir_ls.dap_command", 135 | propertyValue: "disconnect", 136 | samplingFactor: 0.2, 137 | }, 138 | { 139 | eventName: "unhandlederror", 140 | samplingFactor: 0.01, 141 | }, 142 | ]; 143 | 144 | function shouldSampleEvent( 145 | eventName: string, 146 | properties: TelemetryEventProperties | undefined, 147 | config: EventSamplingConfig, 148 | ): boolean { 149 | if (eventName !== config.eventName) { 150 | return false; 151 | } 152 | 153 | if ( 154 | config.propertyKey && 155 | (!properties || properties[config.propertyKey] !== config.propertyValue) 156 | ) { 157 | return false; 158 | } 159 | 160 | return true; 161 | } 162 | 163 | function applySampling( 164 | eventName: string, 165 | properties: TelemetryEventProperties | undefined, 166 | cb: (samplingFactor: number) => void, 167 | ) { 168 | let samplingFactor = 1; // Default sampling factor 169 | 170 | for (const config of samplingConfigs) { 171 | if (shouldSampleEvent(eventName, properties, config)) { 172 | samplingFactor = config.samplingFactor; 173 | break; 174 | } 175 | } 176 | 177 | if (samplingFactor === 1 || Math.random() <= samplingFactor) { 178 | cb(samplingFactor); 179 | } 180 | } 181 | 182 | export let reporter: TelemetryReporter; 183 | 184 | class EnvironmentReporter extends TelemetryReporter { 185 | constructor() { 186 | super(process.env.ELS_TEST ? fakeKey : key); 187 | } 188 | 189 | override sendTelemetryEvent( 190 | eventName: string, 191 | properties?: TelemetryEventProperties | undefined, 192 | measurements?: TelemetryEventMeasurements | undefined, 193 | ): void { 194 | if (process.env.ELS_TEST) { 195 | return; 196 | } 197 | 198 | applySampling(eventName, properties, (samplingFactor) => 199 | super.sendTelemetryEvent( 200 | eventName, 201 | properties, 202 | this.appendCount(eventName, samplingFactor, measurements), 203 | ), 204 | ); 205 | } 206 | 207 | override sendTelemetryErrorEvent( 208 | eventName: string, 209 | properties?: TelemetryEventProperties | undefined, 210 | measurements?: TelemetryEventMeasurements | undefined, 211 | ): void { 212 | if (process.env.ELS_TEST) { 213 | return; 214 | } 215 | 216 | applySampling(eventName, properties, (samplingFactor) => 217 | super.sendTelemetryErrorEvent( 218 | eventName, 219 | properties, 220 | this.appendCount(eventName, samplingFactor, measurements), 221 | ), 222 | ); 223 | } 224 | 225 | override sendRawTelemetryEvent( 226 | eventName: string, 227 | properties?: TelemetryEventProperties | undefined, 228 | measurements?: TelemetryEventMeasurements | undefined, 229 | ): void { 230 | if (process.env.ELS_TEST) { 231 | return; 232 | } 233 | 234 | applySampling(eventName, properties, (samplingFactor) => 235 | super.sendRawTelemetryEvent( 236 | eventName, 237 | properties, 238 | this.appendCount(eventName, samplingFactor, measurements), 239 | ), 240 | ); 241 | } 242 | 243 | override sendDangerousTelemetryErrorEvent( 244 | eventName: string, 245 | properties?: TelemetryEventProperties | undefined, 246 | measurements?: TelemetryEventMeasurements | undefined, 247 | ): void { 248 | if (process.env.ELS_TEST) { 249 | return; 250 | } 251 | 252 | applySampling(eventName, properties, (samplingFactor) => 253 | super.sendDangerousTelemetryErrorEvent( 254 | eventName, 255 | properties, 256 | this.appendCount(eventName, samplingFactor, measurements), 257 | ), 258 | ); 259 | } 260 | 261 | override sendDangerousTelemetryEvent( 262 | eventName: string, 263 | properties?: TelemetryEventProperties | undefined, 264 | measurements?: TelemetryEventMeasurements | undefined, 265 | ): void { 266 | if (process.env.ELS_TEST) { 267 | return; 268 | } 269 | 270 | applySampling(eventName, properties, (samplingFactor) => 271 | super.sendDangerousTelemetryEvent( 272 | eventName, 273 | properties, 274 | this.appendCount(eventName, samplingFactor, measurements), 275 | ), 276 | ); 277 | } 278 | 279 | private appendCount( 280 | eventName: string, 281 | samplingFactor: number, 282 | measurements?: TelemetryEventMeasurements | undefined, 283 | ): TelemetryEventMeasurements { 284 | const label = `elixir_ls.${eventName}_count`; 285 | if (!measurements) { 286 | const measurementsWithCount: TelemetryEventMeasurements = {}; 287 | // biome-ignore lint/suspicious/noExplicitAny: 288 | (measurementsWithCount)[label] = 1 / samplingFactor; 289 | return measurementsWithCount; 290 | } 291 | let countFound = false; 292 | // biome-ignore lint/complexity/noForEach: 293 | Object.keys(measurements).forEach((key) => { 294 | if (key.endsWith("_count")) { 295 | // biome-ignore lint/suspicious/noExplicitAny: 296 | (measurements)[key] /= samplingFactor; 297 | countFound = true; 298 | } 299 | }); 300 | if (!countFound) { 301 | // biome-ignore lint/suspicious/noExplicitAny: 302 | (measurements)[label] = 1 / samplingFactor; 303 | } 304 | return measurements; 305 | } 306 | } 307 | 308 | export interface TelemetryEvent { 309 | name: string; 310 | properties: { [key: string]: string }; 311 | measurements: { [key: string]: number }; 312 | } 313 | 314 | export function configureTelemetry(context: vscode.ExtensionContext) { 315 | reporter = new EnvironmentReporter(); 316 | context.subscriptions.push(reporter); 317 | } 318 | 319 | export function preprocessStacktrace(originalStack: string) { 320 | let stack = originalStack; 321 | // Define the libraries you want to preserve 322 | const libraries = [ 323 | "elixir_sense", 324 | "language_server", 325 | "debug_adapter", 326 | "elixir_ls_utils", 327 | "elixir", 328 | "mix", 329 | "eex", 330 | "ex_unit", 331 | ]; 332 | 333 | for (const library of libraries) { 334 | // Regular expression to capture paths of the library 335 | const libraryPathRegex = new RegExp(`(.*)(/${library}/)([^\\s]+)`, "g"); 336 | 337 | stack = stack.replace(libraryPathRegex, (_, before, libraryPath, after) => { 338 | const modifiedPath = after.replace(/\//g, "_"); 339 | return `USER_PATH_${library}_${modifiedPath}`; 340 | }); 341 | } 342 | 343 | // Sanitize Elixir function arity syntax 344 | stack = stack.replace(/\/\d+/g, (match) => match.replace("/", "_")); 345 | 346 | // Sanitize Elixir key errors 347 | stack = stack.replace(/key (.*?) not found/g, "k_ey $1 not found"); 348 | 349 | stack = stack.replace(/badkey/g, "badk_ey"); 350 | 351 | stack = stack.replace(/bad key/g, "bad k_ey"); 352 | 353 | stack = stack.replace(/unknown key/g, "unknown k_ey"); 354 | 355 | stack = stack.replace(/does not have the key/g, "does not have the k_ey"); 356 | 357 | stack = stack.replace(/token missing/g, "t_oken missing"); 358 | 359 | stack = stack.replace(/unexpected token/g, "unexpected t_oken"); 360 | 361 | stack = stack.replace(/Unexpected token/g, "Unexpected t_oken"); 362 | 363 | stack = stack.replace(/reserved token/g, "reserved t_oken"); 364 | 365 | const sensitiveKeywords = [ 366 | "key", 367 | "token", 368 | "sig", 369 | "secret", 370 | "signature", 371 | "password", 372 | "passwd", 373 | "pwd", 374 | "android:value", 375 | ]; 376 | // biome-ignore lint/complexity/noForEach: 377 | sensitiveKeywords.forEach((keyword) => { 378 | const regex = new RegExp(`(${keyword})[^a-zA-Z0-9]`, "gi"); 379 | const encodeKeyword = `${keyword[0]}_${keyword.slice(1)}`; 380 | stack = stack.replace(regex, encodeKeyword); 381 | }); 382 | 383 | // Workaround for Erlang node names being identified as emails 384 | stack = stack.replace(/@([a-zA-Z0-9-]+\.[a-zA-Z0-9-]+)/g, "_at_$1"); 385 | 386 | return stack; 387 | } 388 | 389 | export function preprocessStacktraceInProperties( 390 | properties?: TelemetryEventProperties | undefined, 391 | ): TelemetryEventProperties | undefined { 392 | if (!properties) { 393 | return properties; 394 | } 395 | 396 | // biome-ignore lint/suspicious/noExplicitAny: 397 | for (const key in properties) { 398 | // biome-ignore lint/suspicious/noExplicitAny: 399 | (properties)[key] = preprocessStacktrace((properties)[key]); 400 | } 401 | return properties; 402 | } 403 | -------------------------------------------------------------------------------- /src/terminalLinkProvider.ts: -------------------------------------------------------------------------------- 1 | import * as path from "node:path"; 2 | import * as vscode from "vscode"; 3 | 4 | interface TerminalLinkWithData extends vscode.TerminalLink { 5 | data: { 6 | app: string; 7 | file: string; 8 | line: number; 9 | }; 10 | } 11 | 12 | export function configureTerminalLinkProvider( 13 | context: vscode.ExtensionContext, 14 | ) { 15 | async function openUri(uri: vscode.Uri, line: number) { 16 | const document = await vscode.workspace.openTextDocument(uri); 17 | const editor = await vscode.window.showTextDocument(document); 18 | const position = new vscode.Position(line - 1, 0); 19 | const selection = new vscode.Selection(position, position); 20 | editor.revealRange(selection); 21 | editor.selection = selection; 22 | } 23 | 24 | const disposable = vscode.window.registerTerminalLinkProvider({ 25 | provideTerminalLinks: ( 26 | context: vscode.TerminalLinkContext, 27 | _token: vscode.CancellationToken, 28 | ): vscode.ProviderResult => { 29 | const regex = 30 | /(?:\((?[_a-z0-9]+) \d+.\d+.\d+\) )(?[_a-z0-9/]*[_a-z0-9]+.ex):(?\d+)/; 31 | const matches = context.line.match(regex); 32 | if (matches === null) { 33 | return []; 34 | } 35 | 36 | return [ 37 | { 38 | // biome-ignore lint/style/noNonNullAssertion: 39 | startIndex: matches.index!, 40 | length: matches[0].length, 41 | data: { 42 | app: matches.groups?.app ?? "", 43 | file: matches.groups?.file ?? "", 44 | line: Number.parseInt(matches.groups?.line ?? "1"), 45 | }, 46 | }, 47 | ]; 48 | }, 49 | handleTerminalLink: async ({ 50 | data: { app, file, line }, 51 | }: TerminalLinkWithData) => { 52 | if (path.isAbsolute(file)) { 53 | const absUri = vscode.Uri.file(file); 54 | const meta = await vscode.workspace.fs.stat(absUri); 55 | if ( 56 | meta?.type & 57 | (vscode.FileType.File | vscode.FileType.SymbolicLink) 58 | ) { 59 | openUri(absUri, line); 60 | } 61 | } else { 62 | const umbrellaFile = path.join("apps", app, file); 63 | const depsFile = path.join("deps", app, file); 64 | const uris = await vscode.workspace.findFiles( 65 | `{${umbrellaFile},${file},${depsFile}}`, 66 | ); 67 | if (uris.length === 1) { 68 | openUri(uris[0], line); 69 | } else if (uris.length > 1) { 70 | const items = uris.map((uri) => ({ label: uri.toString(), uri })); 71 | const selection = await vscode.window.showQuickPick(items); 72 | if (!selection) { 73 | return; 74 | } 75 | 76 | await openUri(selection.uri, line); 77 | } 78 | } 79 | }, 80 | }); 81 | 82 | context.subscriptions.push(disposable); 83 | } 84 | -------------------------------------------------------------------------------- /src/test-fixtures/containing_folder/single_folder_mix/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /src/test-fixtures/containing_folder/single_folder_mix/.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 | single_folder_mix-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /src/test-fixtures/containing_folder/single_folder_mix/README.md: -------------------------------------------------------------------------------- 1 | # SingleFolderMix 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `single_folder_mix` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:single_folder_mix, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at . 21 | -------------------------------------------------------------------------------- /src/test-fixtures/containing_folder/single_folder_mix/lib/single_folder_mix.ex: -------------------------------------------------------------------------------- 1 | defmodule SingleFolderMix do 2 | @moduledoc """ 3 | Documentation for `SingleFolderMix`. 4 | """ 5 | 6 | @doc """ 7 | Hello world. 8 | 9 | ## Examples 10 | 11 | iex> SingleFolderMix.hello() 12 | :world 13 | 14 | """ 15 | def hello do 16 | :world 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /src/test-fixtures/containing_folder/single_folder_mix/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule SingleFolderMix.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :single_folder_mix, 7 | version: "0.1.0", 8 | elixir: "~> 1.14", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | # Run "mix help compile.app" to learn about applications. 15 | def application do 16 | [ 17 | extra_applications: [:logger] 18 | ] 19 | end 20 | 21 | # Run "mix help deps" to learn about dependencies. 22 | defp deps do 23 | [ 24 | # {:dep_from_hexpm, "~> 0.3.0"}, 25 | # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} 26 | ] 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /src/test-fixtures/containing_folder/single_folder_mix/test/single_folder_mix_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SingleFolderMixTest do 2 | use ExUnit.Case 3 | doctest SingleFolderMix 4 | 5 | test "greets the world" do 6 | assert SingleFolderMix.hello() == :world 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /src/test-fixtures/containing_folder/single_folder_mix/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /src/test-fixtures/containing_folder_with_mix/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule SingleFolderMix.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :containing_folder_with_mix, 7 | version: "0.1.0", 8 | elixir: "~> 1.14", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | # Run "mix help compile.app" to learn about applications. 15 | def application do 16 | [ 17 | extra_applications: [:logger] 18 | ] 19 | end 20 | 21 | # Run "mix help deps" to learn about dependencies. 22 | defp deps do 23 | [ 24 | # {:dep_from_hexpm, "~> 0.3.0"}, 25 | # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} 26 | ] 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /src/test-fixtures/containing_folder_with_mix/single_folder_mix/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /src/test-fixtures/containing_folder_with_mix/single_folder_mix/.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 | single_folder_mix-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /src/test-fixtures/containing_folder_with_mix/single_folder_mix/README.md: -------------------------------------------------------------------------------- 1 | # SingleFolderMix 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `single_folder_mix` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:single_folder_mix, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at . 21 | -------------------------------------------------------------------------------- /src/test-fixtures/containing_folder_with_mix/single_folder_mix/lib/single_folder_mix.ex: -------------------------------------------------------------------------------- 1 | defmodule SingleFolderMix do 2 | @moduledoc """ 3 | Documentation for `SingleFolderMix`. 4 | """ 5 | 6 | @doc """ 7 | Hello world. 8 | 9 | ## Examples 10 | 11 | iex> SingleFolderMix.hello() 12 | :world 13 | 14 | """ 15 | def hello do 16 | :world 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /src/test-fixtures/containing_folder_with_mix/single_folder_mix/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule SingleFolderMix.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :single_folder_mix, 7 | version: "0.1.0", 8 | elixir: "~> 1.14", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | # Run "mix help compile.app" to learn about applications. 15 | def application do 16 | [ 17 | extra_applications: [:logger] 18 | ] 19 | end 20 | 21 | # Run "mix help deps" to learn about dependencies. 22 | defp deps do 23 | [ 24 | # {:dep_from_hexpm, "~> 0.3.0"}, 25 | # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} 26 | ] 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /src/test-fixtures/containing_folder_with_mix/single_folder_mix/test/single_folder_mix_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SingleFolderMixTest do 2 | use ExUnit.Case 3 | doctest SingleFolderMix 4 | 5 | test "greets the world" do 6 | assert SingleFolderMix.hello() == :world 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /src/test-fixtures/containing_folder_with_mix/single_folder_mix/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /src/test-fixtures/elixir_file.ex: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-lsp/vscode-elixir-ls/90bcea67a49338f2bc875788682661b66caeacbb/src/test-fixtures/elixir_file.ex -------------------------------------------------------------------------------- /src/test-fixtures/elixir_script.exs: -------------------------------------------------------------------------------- 1 | IO.puts("Hello elixir") 2 | -------------------------------------------------------------------------------- /src/test-fixtures/multi_root.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "sample_umbrella" 5 | }, 6 | { 7 | "path": "sample_umbrella/apps/child1" 8 | }, 9 | { 10 | "path": "single_folder_no_mix" 11 | }, 12 | { 13 | "path": "containing_folder" 14 | }, 15 | { 16 | "path": "containing_folder/single_folder_mix" 17 | } 18 | ], 19 | "settings": {} 20 | } 21 | -------------------------------------------------------------------------------- /src/test-fixtures/non_elixir.txt: -------------------------------------------------------------------------------- 1 | abc 2 | -------------------------------------------------------------------------------- /src/test-fixtures/sample_umbrella/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "config/*.exs"], 4 | subdirectories: ["apps/*"] 5 | ] 6 | -------------------------------------------------------------------------------- /src/test-fixtures/sample_umbrella/.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 | # Temporary files, for example, from tests. 23 | /tmp/ 24 | -------------------------------------------------------------------------------- /src/test-fixtures/sample_umbrella/README.md: -------------------------------------------------------------------------------- 1 | # SampleUmbrella 2 | 3 | **TODO: Add description** 4 | -------------------------------------------------------------------------------- /src/test-fixtures/sample_umbrella/apps/child1/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /src/test-fixtures/sample_umbrella/apps/child1/.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 | child1-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /src/test-fixtures/sample_umbrella/apps/child1/README.md: -------------------------------------------------------------------------------- 1 | # Child1 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `child1` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:child1, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at . 21 | -------------------------------------------------------------------------------- /src/test-fixtures/sample_umbrella/apps/child1/lib/child1.ex: -------------------------------------------------------------------------------- 1 | defmodule Child1 do 2 | @moduledoc """ 3 | Documentation for `Child1`. 4 | """ 5 | 6 | @doc """ 7 | Hello world. 8 | 9 | ## Examples 10 | 11 | iex> Child1.hello() 12 | :world 13 | 14 | """ 15 | def hello do 16 | :world 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /src/test-fixtures/sample_umbrella/apps/child1/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Child1.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :child1, 7 | version: "0.1.0", 8 | build_path: "../../_build", 9 | config_path: "../../config/config.exs", 10 | deps_path: "../../deps", 11 | lockfile: "../../mix.lock", 12 | elixir: "~> 1.14", 13 | start_permanent: Mix.env() == :prod, 14 | deps: deps() 15 | ] 16 | end 17 | 18 | # Run "mix help compile.app" to learn about applications. 19 | def application do 20 | [ 21 | extra_applications: [:logger] 22 | ] 23 | end 24 | 25 | # Run "mix help deps" to learn about dependencies. 26 | defp deps do 27 | [ 28 | # {:dep_from_hexpm, "~> 0.3.0"}, 29 | # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}, 30 | # {:sibling_app_in_umbrella, in_umbrella: true} 31 | ] 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /src/test-fixtures/sample_umbrella/apps/child1/test/child1_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Child1Test do 2 | use ExUnit.Case 3 | doctest Child1 4 | 5 | test "greets the world" do 6 | assert Child1.hello() == :world 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /src/test-fixtures/sample_umbrella/apps/child1/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /src/test-fixtures/sample_umbrella/apps/child2/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /src/test-fixtures/sample_umbrella/apps/child2/.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 | child2-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /src/test-fixtures/sample_umbrella/apps/child2/README.md: -------------------------------------------------------------------------------- 1 | # Child2 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `child2` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:child2, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at . 21 | -------------------------------------------------------------------------------- /src/test-fixtures/sample_umbrella/apps/child2/lib/child2.ex: -------------------------------------------------------------------------------- 1 | defmodule Child2 do 2 | @moduledoc """ 3 | Documentation for `Child2`. 4 | """ 5 | 6 | @doc """ 7 | Hello world. 8 | 9 | ## Examples 10 | 11 | iex> Child2.hello() 12 | :world 13 | 14 | """ 15 | def hello do 16 | :world 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /src/test-fixtures/sample_umbrella/apps/child2/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Child2.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :child2, 7 | version: "0.1.0", 8 | build_path: "../../_build", 9 | config_path: "../../config/config.exs", 10 | deps_path: "../../deps", 11 | lockfile: "../../mix.lock", 12 | elixir: "~> 1.14", 13 | start_permanent: Mix.env() == :prod, 14 | deps: deps() 15 | ] 16 | end 17 | 18 | # Run "mix help compile.app" to learn about applications. 19 | def application do 20 | [ 21 | extra_applications: [:logger] 22 | ] 23 | end 24 | 25 | # Run "mix help deps" to learn about dependencies. 26 | defp deps do 27 | [ 28 | # {:dep_from_hexpm, "~> 0.3.0"}, 29 | # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}, 30 | # {:sibling_app_in_umbrella, in_umbrella: true} 31 | ] 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /src/test-fixtures/sample_umbrella/apps/child2/test/child2_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Child2Test do 2 | use ExUnit.Case 3 | doctest Child2 4 | 5 | test "greets the world" do 6 | assert Child2.hello() == :world 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /src/test-fixtures/sample_umbrella/apps/child2/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /src/test-fixtures/sample_umbrella/config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your umbrella 2 | # and **all applications** and their dependencies with the 3 | # help of the Config module. 4 | # 5 | # Note that all applications in your umbrella share the 6 | # same configuration and dependencies, which is why they 7 | # all use the same configuration file. If you want different 8 | # configurations or dependencies per app, it is best to 9 | # move said applications out of the umbrella. 10 | import Config 11 | 12 | # Sample configuration: 13 | # 14 | # config :logger, :console, 15 | # level: :info, 16 | # format: "$date $time [$level] $metadata$message\n", 17 | # metadata: [:user_id] 18 | # 19 | -------------------------------------------------------------------------------- /src/test-fixtures/sample_umbrella/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule SampleUmbrella.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | apps_path: "apps", 7 | version: "0.1.0", 8 | start_permanent: Mix.env() == :prod, 9 | deps: deps() 10 | ] 11 | end 12 | 13 | # Dependencies listed here are available only for this 14 | # project and cannot be accessed from applications inside 15 | # the apps folder. 16 | # 17 | # Run "mix help deps" for examples and options. 18 | defp deps do 19 | [] 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /src/test-fixtures/single_folder_mix/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /src/test-fixtures/single_folder_mix/.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 | single_folder_mix-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /src/test-fixtures/single_folder_mix/README.md: -------------------------------------------------------------------------------- 1 | # SingleFolderMix 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `single_folder_mix` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:single_folder_mix, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at . 21 | -------------------------------------------------------------------------------- /src/test-fixtures/single_folder_mix/lib/single_folder_mix.ex: -------------------------------------------------------------------------------- 1 | defmodule SingleFolderMix do 2 | @moduledoc """ 3 | Documentation for `SingleFolderMix`. 4 | """ 5 | 6 | @doc """ 7 | Hello world. 8 | 9 | ## Examples 10 | 11 | iex> SingleFolderMix.hello() 12 | :world 13 | 14 | """ 15 | def hello do 16 | :world 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /src/test-fixtures/single_folder_mix/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule SingleFolderMix.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :single_folder_mix, 7 | version: "0.1.0", 8 | elixir: "~> 1.14", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | # Run "mix help compile.app" to learn about applications. 15 | def application do 16 | [ 17 | extra_applications: [:logger] 18 | ] 19 | end 20 | 21 | # Run "mix help deps" to learn about dependencies. 22 | defp deps do 23 | [ 24 | # {:dep_from_hexpm, "~> 0.3.0"}, 25 | # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} 26 | ] 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /src/test-fixtures/single_folder_mix/test/single_folder_mix_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SingleFolderMixTest do 2 | use ExUnit.Case 3 | doctest SingleFolderMix 4 | 5 | test "greets the world" do 6 | assert SingleFolderMix.hello() == :world 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /src/test-fixtures/single_folder_mix/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /src/test-fixtures/single_folder_no_mix/elixir_file.ex: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-lsp/vscode-elixir-ls/90bcea67a49338f2bc875788682661b66caeacbb/src/test-fixtures/single_folder_no_mix/elixir_file.ex -------------------------------------------------------------------------------- /src/test-fixtures/single_folder_no_mix/elixir_script.exs: -------------------------------------------------------------------------------- 1 | IO.puts("Hello elixir") 2 | -------------------------------------------------------------------------------- /src/test/multiRoot/extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "node:assert"; 2 | 3 | import * as path from "node:path"; 4 | // You can import and use all API from the 'vscode' module 5 | // as well as import your extension to test it 6 | import * as vscode from "vscode"; 7 | import type { ElixirLS } from "../../extension"; 8 | import { WorkspaceMode } from "../../project"; 9 | import { 10 | getExtension, 11 | sleep, 12 | waitForLanguageClientManagerUpdate, 13 | waitForWorkspaceUpdate, 14 | } from "../utils"; 15 | 16 | let extension: vscode.Extension; 17 | const fixturesPath = path.resolve(__dirname, "../../../src/test-fixtures"); 18 | 19 | suite("Multi root workspace tests", () => { 20 | vscode.window.showInformationMessage("Start multi root workspace tests."); 21 | 22 | suiteSetup(async () => { 23 | extension = getExtension(); 24 | }); 25 | 26 | test("extension detects mix.exs and actives", async () => { 27 | assert.ok(extension.isActive); 28 | assert.equal( 29 | extension.exports.workspaceTracker.mode, 30 | WorkspaceMode.MULTI_ROOT, 31 | ); 32 | assert.ok(!extension.exports.languageClientManager.defaultClient); 33 | assert.equal(extension.exports.languageClientManager.clients.size, 0); 34 | }).timeout(30000); 35 | 36 | test("extension starts first client on file open", async () => { 37 | const fileUri = vscode.Uri.file( 38 | path.join(fixturesPath, "sample_umbrella", "mix.exs"), 39 | ); 40 | 41 | await waitForLanguageClientManagerUpdate(extension, async () => { 42 | const document = await vscode.workspace.openTextDocument(fileUri); 43 | await vscode.window.showTextDocument(document); 44 | }); 45 | 46 | assert.ok(!extension.exports.languageClientManager.defaultClient); 47 | assert.equal(extension.exports.languageClientManager.clients.size, 1); 48 | }).timeout(30000); 49 | 50 | test("requests from workspace file: docs go to outermost folder client", async () => { 51 | const parentWorkspaceUri = vscode.Uri.file( 52 | path.join(fixturesPath, "sample_umbrella"), 53 | ); 54 | const fileUri = vscode.Uri.file( 55 | path.join(fixturesPath, "sample_umbrella", "mix.exs"), 56 | ); 57 | assert.equal( 58 | extension.exports.languageClientManager.getClientByUri(fileUri), 59 | extension.exports.languageClientManager.clients.get( 60 | parentWorkspaceUri.toString(), 61 | ), 62 | ); 63 | }); 64 | 65 | test("extension does not start client for nested workspace", async () => { 66 | const fileUri = vscode.Uri.file( 67 | path.join(fixturesPath, "sample_umbrella", "apps", "child1", "mix.exs"), 68 | ); 69 | const document = await vscode.workspace.openTextDocument(fileUri); 70 | await vscode.window.showTextDocument(document); 71 | 72 | await sleep(3000); 73 | 74 | assert.ok(!extension.exports.languageClientManager.defaultClient); 75 | assert.equal(extension.exports.languageClientManager.clients.size, 1); 76 | }).timeout(30000); 77 | 78 | test("requests from nested workspace file: docs go to outermost folder client", async () => { 79 | const parentWorkspaceUri = vscode.Uri.file( 80 | path.join(fixturesPath, "sample_umbrella"), 81 | ); 82 | const fileUri = vscode.Uri.file( 83 | path.join(fixturesPath, "sample_umbrella", "apps", "child1", "mix.exs"), 84 | ); 85 | assert.equal( 86 | extension.exports.languageClientManager.getClientByUri(fileUri), 87 | extension.exports.languageClientManager.clients.get( 88 | parentWorkspaceUri.toString(), 89 | ), 90 | ); 91 | }); 92 | 93 | test("requests from untitled: docs go to first workspace client", async () => { 94 | const sampleFileUri = vscode.Uri.parse("untitled:sample.exs"); 95 | assert.equal( 96 | extension.exports.languageClientManager.getClientByUri(sampleFileUri), 97 | extension.exports.languageClientManager.clients.get( 98 | vscode.workspace.workspaceFolders?.[0].uri.toString() ?? "", 99 | ), 100 | ); 101 | }); 102 | 103 | // TODO throw or return null 104 | // test("requests from non workspace file: docs go to first workspace client", async () => { 105 | // const fileUri = vscode.Uri.file(path.join(fixturesPath, "elixir_file.ex")); 106 | // assert.equal( 107 | // languageClientManager.getClientByUri(fileUri), 108 | // languageClientManager.clients.get(vscode.workspace.workspaceFolders![0].uri.toString()) 109 | // ); 110 | // }); 111 | 112 | test("extension starts second client on file open from different outermost folder", async () => { 113 | const fileUri = vscode.Uri.file( 114 | path.join(fixturesPath, "single_folder_no_mix", "elixir_script.exs"), 115 | ); 116 | 117 | await waitForLanguageClientManagerUpdate(extension, async () => { 118 | const document = await vscode.workspace.openTextDocument(fileUri); 119 | await vscode.window.showTextDocument(document); 120 | }); 121 | 122 | assert.ok(!extension.exports.languageClientManager.defaultClient); 123 | assert.equal(extension.exports.languageClientManager.clients.size, 2); 124 | }).timeout(30000); 125 | 126 | test("extension reacts to added and removed workspace folder", async () => { 127 | const addedFolderUri = vscode.Uri.file( 128 | path.join(fixturesPath, "single_folder_mix"), 129 | ); 130 | await waitForWorkspaceUpdate(() => { 131 | vscode.workspace.updateWorkspaceFolders( 132 | vscode.workspace.workspaceFolders?.length ?? 0, 133 | null, 134 | { 135 | uri: addedFolderUri, 136 | name: "single_folder_mix", 137 | }, 138 | ); 139 | }); 140 | 141 | const fileUri = vscode.Uri.file( 142 | path.join(fixturesPath, "single_folder_mix", "mix.exs"), 143 | ); 144 | 145 | await waitForLanguageClientManagerUpdate(extension, async () => { 146 | const document = await vscode.workspace.openTextDocument(fileUri); 147 | await vscode.window.showTextDocument(document); 148 | }); 149 | 150 | assert.ok(!extension.exports.languageClientManager.defaultClient); 151 | assert.equal(extension.exports.languageClientManager.clients.size, 3); 152 | 153 | const addedWorkspaceFolder = 154 | // biome-ignore lint/style/noNonNullAssertion: 155 | vscode.workspace.getWorkspaceFolder(addedFolderUri)!; 156 | 157 | await waitForLanguageClientManagerUpdate(extension, async () => { 158 | vscode.workspace.updateWorkspaceFolders(addedWorkspaceFolder.index, 1); 159 | }); 160 | 161 | assert.equal(extension.exports.languageClientManager.clients.size, 2); 162 | }).timeout(30000); 163 | 164 | test("extension does not react to added and removed nested workspace folder", async () => { 165 | const addedFolderUri = vscode.Uri.file( 166 | path.join(fixturesPath, "sample_umbrella", "apps", "child2"), 167 | ); 168 | await waitForWorkspaceUpdate(() => { 169 | vscode.workspace.updateWorkspaceFolders( 170 | vscode.workspace.workspaceFolders?.length ?? 0, 171 | null, 172 | { 173 | uri: addedFolderUri, 174 | name: "single_folder_mix", 175 | }, 176 | ); 177 | }); 178 | 179 | const fileUri = vscode.Uri.file( 180 | path.join(fixturesPath, "sample_umbrella", "apps", "child2", "mix.exs"), 181 | ); 182 | const document = await vscode.workspace.openTextDocument(fileUri); 183 | await vscode.window.showTextDocument(document); 184 | 185 | await sleep(3000); 186 | 187 | assert.ok(!extension.exports.languageClientManager.defaultClient); 188 | assert.equal(extension.exports.languageClientManager.clients.size, 2); 189 | 190 | const addedWorkspaceFolder = 191 | // biome-ignore lint/style/noNonNullAssertion: 192 | vscode.workspace.getWorkspaceFolder(addedFolderUri)!; 193 | 194 | await waitForWorkspaceUpdate(() => { 195 | vscode.workspace.updateWorkspaceFolders(addedWorkspaceFolder.index, 1); 196 | }); 197 | 198 | await sleep(3000); 199 | 200 | assert.equal(extension.exports.languageClientManager.clients.size, 2); 201 | }).timeout(30000); 202 | 203 | test("extension starts first client on on outermost folder with mix.exs", async () => { 204 | const fileUri = vscode.Uri.file( 205 | path.join( 206 | fixturesPath, 207 | "containing_folder", 208 | "single_folder_mix", 209 | "mix.exs", 210 | ), 211 | ); 212 | 213 | const workspaceFolderUri = vscode.Uri.file( 214 | path.join(fixturesPath, "containing_folder", "single_folder_mix"), 215 | ); 216 | 217 | await waitForLanguageClientManagerUpdate(extension, async () => { 218 | const document = await vscode.workspace.openTextDocument(fileUri); 219 | await vscode.window.showTextDocument(document); 220 | }); 221 | 222 | assert.ok(!extension.exports.languageClientManager.defaultClient); 223 | assert.equal(extension.exports.languageClientManager.clients.size, 3); 224 | 225 | assert.equal( 226 | extension.exports.languageClientManager.getClientByUri(fileUri), 227 | extension.exports.languageClientManager.clients.get( 228 | workspaceFolderUri.toString(), 229 | ), 230 | ); 231 | }).timeout(30000); 232 | 233 | test("extension starts first client on the same folder with mix.exs if useCurrentRootFolderAsProjectDir is true", async () => { 234 | vscode.workspace 235 | .getConfiguration("elixirLS") 236 | .update( 237 | "useCurrentRootFolderAsProjectDir", 238 | true, 239 | vscode.ConfigurationTarget.WorkspaceFolder, 240 | ); 241 | 242 | const fileUri = vscode.Uri.file( 243 | path.join( 244 | fixturesPath, 245 | "containing_folder", 246 | "single_folder_mix", 247 | "mix.exs", 248 | ), 249 | ); 250 | 251 | const workspaceFolderUri = vscode.Uri.file( 252 | path.join(fixturesPath, "containing_folder", "single_folder_mix"), 253 | ); 254 | 255 | await waitForLanguageClientManagerUpdate(extension, async () => { 256 | const document = await vscode.workspace.openTextDocument(fileUri); 257 | await vscode.window.showTextDocument(document); 258 | }); 259 | 260 | assert.ok(!extension.exports.languageClientManager.defaultClient); 261 | assert.equal(extension.exports.languageClientManager.clients.size, 3); 262 | 263 | assert.equal( 264 | extension.exports.languageClientManager.getClientByUri(fileUri), 265 | extension.exports.languageClientManager.clients.get( 266 | workspaceFolderUri.toString(), 267 | ), 268 | ); 269 | }).timeout(30000); 270 | }); 271 | -------------------------------------------------------------------------------- /src/test/multiRoot/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from "node:path"; 2 | import { glob } from "glob"; 3 | import * as Mocha from "mocha"; 4 | 5 | export async function run( 6 | testsRoot: string, 7 | cb: (error: unknown, failures?: number) => void, 8 | ) { 9 | // Create the mocha test 10 | const mocha = new Mocha({ 11 | ui: "tdd", 12 | color: true, 13 | }); 14 | 15 | try { 16 | const files = await glob("**/**.test.js", { cwd: testsRoot }); 17 | // Add files to the test suite 18 | // biome-ignore lint/complexity/noForEach: 19 | files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f))); 20 | 21 | try { 22 | // Run the mocha test 23 | mocha.run((failures) => { 24 | cb(null, failures); 25 | }); 26 | } catch (err) { 27 | cb(err); 28 | } 29 | } catch (globError) { 30 | cb(globError); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/test/noWorkspace/extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "node:assert"; 2 | 3 | // You can import and use all API from the 'vscode' module 4 | // as well as import your extension to test it 5 | import * as vscode from "vscode"; 6 | import { WorkspaceMode } from "../../project"; 7 | import { getActiveExtensionAsync } from "../utils"; 8 | 9 | suite("No workspace non elixir file opened tests", () => { 10 | vscode.window.showInformationMessage("Start no workspace tests."); 11 | 12 | test("activates and starts default client when untitled: .exs opened", async () => { 13 | const sampleFileUri = vscode.Uri.parse("untitled:sample.exs"); 14 | const document = await vscode.workspace.openTextDocument(sampleFileUri); 15 | await vscode.window.showTextDocument(document); 16 | 17 | const extension = await getActiveExtensionAsync(); 18 | 19 | assert.equal( 20 | extension.exports.workspaceTracker.mode, 21 | WorkspaceMode.NO_WORKSPACE, 22 | ); 23 | assert.ok(extension.exports.languageClientManager.defaultClientPromise); 24 | assert.equal(extension.exports.languageClientManager.clients.size, 0); 25 | }).timeout(30000); 26 | }); 27 | -------------------------------------------------------------------------------- /src/test/noWorkspace/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from "node:path"; 2 | import { glob } from "glob"; 3 | import * as Mocha from "mocha"; 4 | 5 | export async function run( 6 | testsRoot: string, 7 | cb: (error: unknown, failures?: number) => void, 8 | ) { 9 | // Create the mocha test 10 | const mocha = new Mocha({ 11 | ui: "tdd", 12 | color: true, 13 | }); 14 | 15 | try { 16 | const files = await glob("**/**.test.js", { cwd: testsRoot }); 17 | // Add files to the test suite 18 | // biome-ignore lint/complexity/noForEach: 19 | files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f))); 20 | 21 | try { 22 | // Run the mocha test 23 | mocha.run((failures) => { 24 | cb(null, failures); 25 | }); 26 | } catch (err) { 27 | cb(err); 28 | } 29 | } catch (globError) { 30 | cb(globError); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/test/noWorkspaceElixirFile/extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "node:assert"; 2 | 3 | import * as path from "node:path"; 4 | // You can import and use all API from the 'vscode' module 5 | // as well as import your extension to test it 6 | import * as vscode from "vscode"; 7 | import type { ElixirLS } from "../../extension"; 8 | import { WorkspaceMode } from "../../project"; 9 | import { getExtension } from "../utils"; 10 | 11 | let extension: vscode.Extension; 12 | const fixturesPath = path.resolve(__dirname, "../../../src/test-fixtures"); 13 | 14 | suite("No workspace elixir file opened tests", () => { 15 | vscode.window.showInformationMessage("Start no workspace tests."); 16 | 17 | suiteSetup(async () => { 18 | extension = getExtension(); 19 | }); 20 | 21 | test("extension is active and starts default client", async () => { 22 | assert.ok(extension.isActive); 23 | assert.equal( 24 | extension.exports.workspaceTracker.mode, 25 | WorkspaceMode.NO_WORKSPACE, 26 | ); 27 | assert.ok(extension.exports.languageClientManager.defaultClient); 28 | assert.equal(extension.exports.languageClientManager.clients.size, 0); 29 | }).timeout(30000); 30 | 31 | // test("starts default client when untitled: .exs opened", async () => { 32 | // const sampleFileUri = vscode.Uri.parse("untitled:sample.exs"); 33 | // const document = await vscode.workspace.openTextDocument(sampleFileUri); 34 | // await vscode.window.showTextDocument(document); 35 | // assert.ok(languageClientManager.defaultClientPromise); 36 | // assert.equal(languageClientManager.clients.size, 0); 37 | // }).timeout(30000); 38 | 39 | test("requests from untitled: docs go to default client", async () => { 40 | const sampleFileUri = vscode.Uri.parse("untitled:sample.exs"); 41 | assert.equal( 42 | extension.exports.languageClientManager.getClientByUri(sampleFileUri), 43 | extension.exports.languageClientManager.defaultClient, 44 | ); 45 | }); 46 | 47 | test("requests from file: docs go to default client", async () => { 48 | const sampleFileUri = vscode.Uri.file( 49 | path.join(fixturesPath, "elixir_file.ex"), 50 | ); 51 | assert.equal( 52 | extension.exports.languageClientManager.getClientByUri(sampleFileUri), 53 | extension.exports.languageClientManager.defaultClient, 54 | ); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/test/noWorkspaceElixirFile/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from "node:path"; 2 | import { glob } from "glob"; 3 | import * as Mocha from "mocha"; 4 | 5 | export async function run( 6 | testsRoot: string, 7 | cb: (error: unknown, failures?: number) => void, 8 | ) { 9 | // Create the mocha test 10 | const mocha = new Mocha({ 11 | ui: "tdd", 12 | color: true, 13 | }); 14 | 15 | try { 16 | const files = await glob("**/**.test.js", { cwd: testsRoot }); 17 | // Add files to the test suite 18 | // biome-ignore lint/complexity/noForEach: 19 | files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f))); 20 | 21 | try { 22 | // Run the mocha test 23 | mocha.run((failures) => { 24 | cb(null, failures); 25 | }); 26 | } catch (err) { 27 | cb(err); 28 | } 29 | } catch (globError) { 30 | cb(globError); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/test/runTest.ts: -------------------------------------------------------------------------------- 1 | import * as path from "node:path"; 2 | 3 | import { runTests } from "@vscode/test-electron"; 4 | 5 | async function main(): Promise { 6 | try { 7 | // The folder containing the Extension Manifest package.json 8 | // Passed to `--extensionDevelopmentPath` 9 | const extensionDevelopmentPath = path.resolve(__dirname, "../../"); 10 | 11 | const disableGpu = process.env.DISABLE_GPU === "1" ? ["--disable-gpu"] : []; 12 | 13 | // single elixir file no workspace 14 | await runTests({ 15 | extensionDevelopmentPath, 16 | extensionTestsPath: path.resolve(__dirname, "./noWorkspaceElixirFile"), 17 | launchArgs: [ 18 | ...disableGpu, 19 | path.resolve(__dirname, "../../src/test-fixtures/elixir_script.exs"), 20 | ], 21 | extensionTestsEnv: { 22 | ELS_TEST: "1", 23 | }, 24 | }); 25 | 26 | // single non elixir file no workspace 27 | await runTests({ 28 | extensionDevelopmentPath, 29 | extensionTestsPath: path.resolve(__dirname, "./noWorkspace"), 30 | launchArgs: [ 31 | ...disableGpu, 32 | path.resolve(__dirname, "../../src/test-fixtures/non_elixir.txt"), 33 | ], 34 | extensionTestsEnv: { 35 | ELS_TEST: "1", 36 | }, 37 | }); 38 | 39 | // single folder no mix 40 | await runTests({ 41 | extensionDevelopmentPath, 42 | extensionTestsPath: path.resolve(__dirname, "./singleFolderNoMix"), 43 | launchArgs: [ 44 | ...disableGpu, 45 | path.resolve(__dirname, "../../src/test-fixtures/single_folder_no_mix"), 46 | ], 47 | extensionTestsEnv: { 48 | ELS_TEST: "1", 49 | }, 50 | }); 51 | 52 | // single folder mix 53 | await runTests({ 54 | extensionDevelopmentPath, 55 | extensionTestsPath: path.resolve(__dirname, "./singleFolderMix"), 56 | launchArgs: [ 57 | ...disableGpu, 58 | path.resolve(__dirname, "../../src/test-fixtures/single_folder_mix"), 59 | ], 60 | extensionTestsEnv: { 61 | ELS_TEST: "1", 62 | }, 63 | }); 64 | 65 | // multi root 66 | await runTests({ 67 | extensionDevelopmentPath, 68 | extensionTestsPath: path.resolve(__dirname, "./multiRoot"), 69 | launchArgs: [ 70 | ...disableGpu, 71 | path.resolve( 72 | __dirname, 73 | "../../src/test-fixtures/multi_root.code-workspace", 74 | ), 75 | ], 76 | extensionTestsEnv: { 77 | ELS_TEST: "1", 78 | }, 79 | }); 80 | } catch (err) { 81 | console.error(err); 82 | console.error("Failed to run tests"); 83 | process.exit(1); 84 | } 85 | } 86 | 87 | main(); 88 | -------------------------------------------------------------------------------- /src/test/singleFolderMix/extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "node:assert"; 2 | 3 | import * as path from "node:path"; 4 | // You can import and use all API from the 'vscode' module 5 | // as well as import your extension to test it 6 | import * as vscode from "vscode"; 7 | import type { ElixirLS } from "../../extension"; 8 | import { WorkspaceMode } from "../../project"; 9 | import { getExtension, waitForLanguageClientManagerUpdate } from "../utils"; 10 | 11 | let extension: vscode.Extension; 12 | const fixturesPath = path.resolve(__dirname, "../../../src/test-fixtures"); 13 | 14 | suite("Single folder no mix tests", () => { 15 | vscode.window.showInformationMessage( 16 | "Start single folder mix project tests.", 17 | ); 18 | 19 | suiteSetup(async () => { 20 | extension = getExtension(); 21 | }); 22 | 23 | test("extension detects mix.exs and actives", async () => { 24 | assert.ok(extension.isActive); 25 | assert.equal( 26 | extension.exports.workspaceTracker.mode, 27 | WorkspaceMode.SINGLE_FOLDER, 28 | ); 29 | assert.ok(!extension.exports.languageClientManager.defaultClient); 30 | // TODO start client? 31 | assert.equal(extension.exports.languageClientManager.clients.size, 0); 32 | }).timeout(30000); 33 | 34 | test("extension starts client on file open", async () => { 35 | const fileUri = vscode.Uri.file( 36 | path.join( 37 | fixturesPath, 38 | "single_folder_mix", 39 | "lib", 40 | "single_folder_mix.ex", 41 | ), 42 | ); 43 | 44 | await waitForLanguageClientManagerUpdate(extension, async () => { 45 | const document = await vscode.workspace.openTextDocument(fileUri); 46 | await vscode.window.showTextDocument(document); 47 | }); 48 | 49 | assert.ok(!extension.exports.languageClientManager.defaultClient); 50 | assert.equal(extension.exports.languageClientManager.clients.size, 1); 51 | }).timeout(30000); 52 | 53 | test("requests from untitled: docs go to first workspace client", async () => { 54 | const sampleFileUri = vscode.Uri.parse("untitled:sample.exs"); 55 | assert.equal( 56 | extension.exports.languageClientManager.getClientByUri(sampleFileUri), 57 | extension.exports.languageClientManager.clients.get( 58 | vscode.workspace.workspaceFolders?.[0].uri.toString() ?? "", 59 | ), 60 | ); 61 | }); 62 | 63 | test("requests from workspace file: docs go to first workspace client", async () => { 64 | const fileUri = vscode.Uri.file( 65 | path.join( 66 | fixturesPath, 67 | "single_folder_mix", 68 | "lib", 69 | "single_folder_mix.ex", 70 | ), 71 | ); 72 | assert.equal( 73 | extension.exports.languageClientManager.getClientByUri(fileUri), 74 | extension.exports.languageClientManager.clients.get( 75 | vscode.workspace.workspaceFolders?.[0].uri.toString() ?? "", 76 | ), 77 | ); 78 | }); 79 | 80 | test("requests from non workspace file: docs go to first workspace client", async () => { 81 | const fileUri = vscode.Uri.file(path.join(fixturesPath, "elixir_file.ex")); 82 | assert.equal( 83 | extension.exports.languageClientManager.getClientByUri(fileUri), 84 | extension.exports.languageClientManager.clients.get( 85 | vscode.workspace.workspaceFolders?.[0].uri.toString() ?? "", 86 | ), 87 | ); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /src/test/singleFolderMix/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from "node:path"; 2 | import { glob } from "glob"; 3 | import * as Mocha from "mocha"; 4 | 5 | export async function run( 6 | testsRoot: string, 7 | cb: (error: unknown, failures?: number) => void, 8 | ) { 9 | // Create the mocha test 10 | const mocha = new Mocha({ 11 | ui: "tdd", 12 | color: true, 13 | }); 14 | 15 | try { 16 | const files = await glob("**/**.test.js", { cwd: testsRoot }); 17 | // Add files to the test suite 18 | // biome-ignore lint/complexity/noForEach: 19 | files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f))); 20 | 21 | try { 22 | // Run the mocha test 23 | mocha.run((failures) => { 24 | cb(null, failures); 25 | }); 26 | } catch (err) { 27 | cb(err); 28 | } 29 | } catch (globError) { 30 | cb(globError); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/test/singleFolderNoMix/extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "node:assert"; 2 | 3 | import * as path from "node:path"; 4 | // You can import and use all API from the 'vscode' module 5 | // as well as import your extension to test it 6 | import * as vscode from "vscode"; 7 | 8 | import { getActiveExtensionAsync } from "../utils"; 9 | 10 | const fixturesPath = path.resolve(__dirname, "../../../src/test-fixtures"); 11 | 12 | suite("Single folder no mix tests", () => { 13 | vscode.window.showInformationMessage("Start single folder no mix tests."); 14 | 15 | test("extension activates on file open", async () => { 16 | const fileUri = vscode.Uri.file( 17 | path.join(fixturesPath, "single_folder_no_mix", "elixir_file.ex"), 18 | ); 19 | const document = await vscode.workspace.openTextDocument(fileUri); 20 | await vscode.window.showTextDocument(document); 21 | 22 | const extension = await getActiveExtensionAsync(); 23 | 24 | assert.ok(extension.isActive); 25 | 26 | assert.ok(!extension.exports.languageClientManager.defaultClient); 27 | assert.equal(extension.exports.languageClientManager.clients.size, 1); 28 | }).timeout(30000); 29 | 30 | test("requests from untitled: docs go to first workspace client", async () => { 31 | const sampleFileUri = vscode.Uri.parse("untitled:sample.exs"); 32 | const extension = await getActiveExtensionAsync(); 33 | assert.equal( 34 | extension.exports.languageClientManager.getClientByUri(sampleFileUri), 35 | extension.exports.languageClientManager.clients.get( 36 | vscode.workspace.workspaceFolders?.[0].uri.toString() ?? "", 37 | ), 38 | ); 39 | }); 40 | 41 | test("requests from workspace file: docs go to first workspace client", async () => { 42 | const fileUri = vscode.Uri.file( 43 | path.join(fixturesPath, "single_folder_no_mix", "elixir_file.ex"), 44 | ); 45 | const extension = await getActiveExtensionAsync(); 46 | assert.equal( 47 | extension.exports.languageClientManager.getClientByUri(fileUri), 48 | extension.exports.languageClientManager.clients.get( 49 | vscode.workspace.workspaceFolders?.[0].uri.toString() ?? "", 50 | ), 51 | ); 52 | }); 53 | 54 | test("requests from non workspace file: docs go to first workspace client", async () => { 55 | const fileUri = vscode.Uri.file(path.join(fixturesPath, "elixir_file.ex")); 56 | const extension = await getActiveExtensionAsync(); 57 | assert.equal( 58 | extension.exports.languageClientManager.getClientByUri(fileUri), 59 | extension.exports.languageClientManager.clients.get( 60 | vscode.workspace.workspaceFolders?.[0].uri.toString() ?? "", 61 | ), 62 | ); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/test/singleFolderNoMix/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from "node:path"; 2 | import { glob } from "glob"; 3 | import * as Mocha from "mocha"; 4 | 5 | export async function run( 6 | testsRoot: string, 7 | cb: (error: unknown, failures?: number) => void, 8 | ) { 9 | // Create the mocha test 10 | const mocha = new Mocha({ 11 | ui: "tdd", 12 | color: true, 13 | }); 14 | 15 | try { 16 | const files = await glob("**/**.test.js", { cwd: testsRoot }); 17 | // Add files to the test suite 18 | // biome-ignore lint/complexity/noForEach: 19 | files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f))); 20 | 21 | try { 22 | // Run the mocha test 23 | mocha.run((failures) => { 24 | cb(null, failures); 25 | }); 26 | } catch (err) { 27 | cb(err); 28 | } 29 | } catch (globError) { 30 | cb(globError); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/test/utils.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { ELIXIR_LS_EXTENSION_NAME } from "../constants"; 3 | import type { ElixirLS } from "../extension"; 4 | 5 | export const getExtension = () => 6 | vscode.extensions.getExtension( 7 | ELIXIR_LS_EXTENSION_NAME, 8 | ) as vscode.Extension; 9 | 10 | const exponentialTimeouts = [ 11 | 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 50000, 12 | ]; 13 | 14 | export const getActiveExtensionAsync = async () => { 15 | const ext = vscode.extensions.getExtension( 16 | ELIXIR_LS_EXTENSION_NAME, 17 | ) as vscode.Extension; 18 | if (ext.isActive) { 19 | return ext; 20 | } 21 | 22 | for (const timeout of exponentialTimeouts) { 23 | await sleep(timeout); 24 | if (ext.isActive) { 25 | return ext; 26 | } 27 | } 28 | throw "timed out"; 29 | }; 30 | 31 | export const sleep = (timeout: number) => 32 | new Promise((resolve) => { 33 | setTimeout(resolve, timeout); 34 | }); 35 | 36 | export const waitForWorkspaceUpdate = (fun: () => void) => 37 | new Promise((resolve, reject) => { 38 | const disposable = vscode.workspace.onDidChangeWorkspaceFolders(() => { 39 | disposable.dispose(); 40 | resolve(undefined); 41 | }); 42 | try { 43 | fun(); 44 | } catch (e) { 45 | reject(e); 46 | } 47 | }); 48 | 49 | export const waitForLanguageClientManagerUpdate = ( 50 | extension: vscode.Extension, 51 | fun: () => void, 52 | ) => 53 | new Promise((resolve, reject) => { 54 | const disposable = extension.exports.languageClientManager.onDidChange( 55 | () => { 56 | disposable.dispose(); 57 | resolve(undefined); 58 | }, 59 | ); 60 | try { 61 | fun(); 62 | } catch (e) { 63 | reject(e); 64 | } 65 | }); 66 | -------------------------------------------------------------------------------- /src/testElixir.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "node:child_process"; 2 | import * as vscode from "vscode"; 3 | import { buildCommand } from "./executable"; 4 | 5 | function testElixirCommand(command: string): false | Buffer { 6 | try { 7 | return execSync(`${command} -e " "`); 8 | } catch { 9 | return false; 10 | } 11 | } 12 | 13 | export function testElixir(context: vscode.ExtensionContext): boolean { 14 | // Use the same script infrastructure as the language server to ensure 15 | // consistent environment setup (version managers, etc.) 16 | const checkCommand = buildCommand(context, "elixir_check", undefined); 17 | const testResult = testElixirCommand(`"${checkCommand}"`); 18 | 19 | if (!testResult) { 20 | vscode.window.showErrorMessage( 21 | "Failed to run elixir check command. ElixirLS will probably fail to launch. Logged PATH to Development Console.", 22 | ); 23 | console.warn( 24 | `Failed to run elixir check command. Current process's PATH: ${process.env.PATH}`, 25 | ); 26 | return false; 27 | } 28 | if (testResult.length > 0) { 29 | vscode.window.showErrorMessage( 30 | "Running elixir check command caused extraneous print to stdout. See VS Code's developer console for details.", 31 | ); 32 | console.warn( 33 | `Running elixir check command printed to stdout:\n${testResult.toString()}`, 34 | ); 35 | return false; 36 | } 37 | return true; 38 | } 39 | -------------------------------------------------------------------------------- /syntaxes/eex.json: -------------------------------------------------------------------------------- 1 | { 2 | "fileTypes": ["eex", "leex"], 3 | "name": "Embedded Elixir", 4 | "patterns": [ 5 | { 6 | "begin": "<%+#", 7 | "captures": { 8 | "0": { 9 | "name": "punctuation.definition.comment.eex" 10 | } 11 | }, 12 | "end": "%>", 13 | "name": "comment.block.eex" 14 | }, 15 | { 16 | "begin": "<%+(?!>)[-=]*", 17 | "captures": { 18 | "0": { 19 | "name": "punctuation.section.embedded.elixir" 20 | } 21 | }, 22 | "end": "-?%>", 23 | "name": "meta.embedded.line.elixir", 24 | "patterns": [ 25 | { 26 | "captures": { 27 | "1": { 28 | "name": "punctuation.definition.comment.elixir" 29 | } 30 | }, 31 | "match": "(#).*?(?=-?%>)", 32 | "name": "comment.line.number-sign.elixir" 33 | }, 34 | { 35 | "include": "source.elixir" 36 | } 37 | ] 38 | } 39 | ], 40 | "scopeName": "text.elixir" 41 | } 42 | -------------------------------------------------------------------------------- /syntaxes/html-eex.json: -------------------------------------------------------------------------------- 1 | { 2 | "fileTypes": ["html.eex", "html.leex"], 3 | "foldingStartMarker": "(?x)\n\t\t(<(?i:head|body|table|thead|tbody|tfoot|tr|div|select|fieldset|style|script|ul|ol|form|dl)\\b.*?>\n\t\t|)\n\t\t|\\{\\s*($|\\?>\\s*$|//|/\\*(.*\\*/\\s*$|(?!.*?\\*/)))\n\t\t)", 4 | "foldingStopMarker": "(?x)\n\t\t(\n\t\t|^\\s*-->\n\t\t|(^|\\s)\\}\n\t\t)", 5 | "injections": { 6 | "R:text.html.elixir meta.tag meta.attribute string.quoted": { 7 | "comment": "Uses R: to ensure this matches after any other injections.", 8 | "patterns": [ 9 | { 10 | "include": "text.elixir" 11 | } 12 | ] 13 | } 14 | }, 15 | "name": "HTML (Embedded Elixir)", 16 | "patterns": [ 17 | { 18 | "include": "text.elixir" 19 | }, 20 | { 21 | "include": "text.html.basic" 22 | } 23 | ], 24 | "scopeName": "text.html.elixir" 25 | } 26 | -------------------------------------------------------------------------------- /telemetry.json: -------------------------------------------------------------------------------- 1 | { 2 | "events": { 3 | "debug_session_starting": { 4 | "elixir_ls.debug_session_mode": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Launch configuration mode"} 5 | }, 6 | "debug_session_error": { 7 | "elixir_ls.debug_session_mode": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Launch configuration mode"}, 8 | "elixir_ls.debug_session_error": {"classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Debug session error"}, 9 | "elixir_ls.debug_session_error_stack": {"classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Debug session error stacktrace"} 10 | }, 11 | "debug_session_exit": { 12 | "elixir_ls.debug_session_mode": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Launch configuration mode"}, 13 | "elixir_ls.debug_session_exit_code": {"classification": "PublicNonPersonalData", "purpose": "PerformanceAndHealth", "comment": "Debug session exit code"}, 14 | "elixir_ls.debug_session_exit_signal": {"classification": "PublicNonPersonalData", "purpose": "PerformanceAndHealth", "comment": "Debug session exit signal"} 15 | }, 16 | "debug_session_initialized": { 17 | "elixir_ls.debug_session_mode": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Launch configuration mode"} 18 | }, 19 | "debug_session_debuggee_exited": { 20 | "elixir_ls.debug_session_mode": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Launch configuration mode"}, 21 | "elixir_ls.debug_session_debuggee_exit_code": {"classification": "PublicNonPersonalData", "purpose": "PerformanceAndHealth", "comment": "Debug session debuggee exit code"} 22 | }, 23 | "debuggee_mix_task_error": { 24 | "elixir_ls.debuggee_mix_task_error": {"classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Debuggee mix task stack trace"} 25 | }, 26 | 27 | "dap_request_error": { 28 | "elixir_ls.debug_session_mode": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Launch configuration mode"}, 29 | "elixir_ls.dap_command": {"classification": "PublicNonPersonalData", "purpose": "PerformanceAndHealth", "comment": "DAP command issued by client"}, 30 | "elixir_ls.dap_error": {"classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "DAP error code"}, 31 | "elixir_ls.dap_error_message": {"classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "DAP error message"} 32 | }, 33 | "dap_server_error": { 34 | "elixir_ls.dap_server_error": {"classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Debugger crash stacktrace"} 35 | }, 36 | "dap_request": { 37 | "elixir_ls.dap_command": {"classification": "PublicNonPersonalData", "purpose": "PerformanceAndHealth", "comment": "DAP command issued by client"} 38 | }, 39 | 40 | "extension_activated": { 41 | "elixir_ls.workspace_mode": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "VSCode workspace mode"} 42 | }, 43 | "extension_deactivated": { 44 | "elixir_ls.workspace_mode": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "VSCode workspace mode"} 45 | }, 46 | 47 | "restart_command": { }, 48 | 49 | "run_test": { 50 | "elixir_ls.with_debug": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Test run mode"} 51 | }, 52 | "run_test_error": { 53 | "elixir_ls.with_debug": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Test run mode"}, 54 | "elixir_ls.run_test_error": {"classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Run test error"}, 55 | "elixir_ls.run_test_error_stack": {"classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Run test error stacktrace"} 56 | }, 57 | 58 | "language_client_starting": { 59 | "elixir_ls.language_client_mode": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Language client mode"} 60 | }, 61 | "language_client_start_error": { 62 | "elixir_ls.language_client_mode": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Language client mode"}, 63 | "elixir_ls.language_client_start_error": {"classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Language client start error"}, 64 | "elixir_ls.language_client_start_error_stack": {"classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Language client start error stacktrace"} 65 | }, 66 | "language_client_restarting": { 67 | "elixir_ls.language_client_mode": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Language client mode"} 68 | }, 69 | "language_client_started": { 70 | "elixir_ls.language_client_mode": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Language client mode"} 71 | }, 72 | "language_client_restart_error": { 73 | "elixir_ls.language_client_mode": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Language client mode"}, 74 | "elixir_ls.language_client_start_error": {"classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Language client start error"}, 75 | "elixir_ls.language_client_start_error_stack": {"classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Language client start error stacktrace"} 76 | }, 77 | "language_client_stop_error": { 78 | "elixir_ls.language_client_stop_error": {"classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Language client stop error"}, 79 | "elixir_ls.language_client_stop_error_stack": {"classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Language client stop error stacktrace"} 80 | }, 81 | "lsp_reload": { 82 | "elixir_ls.lsp_reload_reason": {"classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Language server reload reason"} 83 | }, 84 | "lsp_server_error": { 85 | "elixir_ls.lsp_server_error": {"classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Language server crash stacktrace"} 86 | }, 87 | "lsp_request": { 88 | "elixir_ls.lsp_command": {"classification": "PublicNonPersonalData", "purpose": "PerformanceAndHealth", "comment": "LSP command issued by client"} 89 | }, 90 | "lsp_request_error": { 91 | "elixir_ls.lsp_command": {"classification": "PublicNonPersonalData", "purpose": "PerformanceAndHealth", "comment": "LSP command issued by client"}, 92 | "elixir_ls.lsp_error": {"classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "LSP error code"}, 93 | "elixir_ls.lsp_error_message": {"classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "LSP error message"} 94 | }, 95 | "lsp_reverse_request_error": { 96 | "elixir_ls.lsp_reverse_request_error": {"classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "LSP reverse request error message"}, 97 | "elixir_ls.lsp_reverse_request": {"classification": "PublicNonPersonalData", "purpose": "PerformanceAndHealth", "comment": "LSP reverse request command"} 98 | }, 99 | 100 | "parser_error": { 101 | "elixir_ls.parser_error": {"classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Parser error message"} 102 | }, 103 | 104 | "folding_ranges_error": { 105 | "elixir_ls.folding_ranges_error": {"classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Folding ranges error message"} 106 | }, 107 | 108 | "build_error": { 109 | "elixir_ls.build_error": {"classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Build error message"} 110 | }, 111 | "build": { 112 | "elixir_ls.build_result": {"classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Build result kind"} 113 | }, 114 | "mix_clean_error": { 115 | "elixir_ls.mix_clean_error": {"classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Mix clean error message"} 116 | }, 117 | 118 | "dialyzer_error": { 119 | "elixir_ls.dialyzer_error": {"classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Dialyzer error message"} 120 | }, 121 | "dialyzer": { }, 122 | 123 | "eep48": { 124 | "elixir_ls.eep48": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Is OTP compiled with EEP48 support"} 125 | }, 126 | "elixir_sources": { 127 | "elixir_ls.elixir_sources": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Are elixir sources available"} 128 | }, 129 | "otp_sources": { 130 | "elixir_ls.otp_sources": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Are OTP sources available"} 131 | }, 132 | "dialyzer_support": { 133 | "elixir_ls.dialyzer_support": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Is dialyzer supported"}, 134 | "elixir_ls.dialyzer_support_reason": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Reason why dialyzer is not supported"} 135 | }, 136 | "lsp_config": { 137 | "elixir_ls.projectDir": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Is project dir overriden"}, 138 | "elixir_ls.useCurrentRootFolderAsProjectDir": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Is project dir set to the current root in multi-root workspace"}, 139 | "elixir_ls.autoBuild": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Is auto build enabled"}, 140 | "elixir_ls.dialyzerEnabled": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Is dialyzer enabled"}, 141 | "elixir_ls.fetchDeps": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Is auto fetching of dependencies enabled"}, 142 | "elixir_ls.suggestSpecs": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Is suggest spec code lense enabled"}, 143 | "elixir_ls.autoInsertRequiredAlias": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Is auto alias on complete enabled"}, 144 | "elixir_ls.signatureAfterComplete": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Is signature after complete enabled"}, 145 | "elixir_ls.enableTestLenses": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Is test code lense enabled"}, 146 | "elixir_ls.languageServerOverridePath": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Is language server binary path overriden"}, 147 | "elixir_ls.envVariables": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Are custom environment variables set"}, 148 | "elixir_ls.mixEnv": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Mix env setting"}, 149 | "elixir_ls.mixTarget": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Mix target setting"}, 150 | "elixir_ls.dialyzerFormat": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Selected dialyzer warning format"} 151 | }, 152 | "dap_launch_config": { 153 | "elixir_ls.startApps": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Is auto start of applications enabled"}, 154 | "elixir_ls.debugAutoInterpretAllModules": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Is auto interpreting of modules enabled"}, 155 | "elixir_ls.stackTraceMode": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Debugger stacktrace mode"}, 156 | "elixir_ls.exitAfterTaskReturns": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Should debugger exit after mix task returns"}, 157 | "elixir_ls.noDebug": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Is debug disabled"}, 158 | "elixir_ls.breakOnDbg": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Is auto break on Kernel.dbg enabled"}, 159 | "elixir_ls.env": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Are environment variables specified"}, 160 | "elixir_ls.requireFiles": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Are files required"}, 161 | "elixir_ls.debugInterpretModulesPatterns": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Are module interpret patterns set"}, 162 | "elixir_ls.excludeModules": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Are excluded modules specified"}, 163 | "elixir_ls.task": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Mix task launched"} 164 | } 165 | }, 166 | "commonProperties": { 167 | "common.extname": { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight", "comment": "The name of the ElixirLS extension" }, 168 | "common.extversion": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The version of the ElixirLS extension" }, 169 | "common.vscodemachineid": { "classification": "EndUserPseudonymizedInformation", "purpose": "PerformanceAndHealth", "comment": "A common machine identifier generated by VS Code" }, 170 | "common.vscodesessionid": { "classification": "EndUserPseudonymizedInformation", "purpose": "PerformanceAndHealth", "comment": "A session identifier generated by VS Code" }, 171 | "common.vscodeversion": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The version of VS Code running the extension" }, 172 | "common.os": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The OS running VS Code" }, 173 | "common.platformversion": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The version of the OS/Platform" }, 174 | "common.product": { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight", "comment": "What Vs code is hosted in, i.e. desktop, github.dev, codespaces" }, 175 | "common.uikind": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Web or Desktop indicating where VS Code is running" }, 176 | "common.remotename": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "A name to identify the type of remote connection. other indicates a remote connection not from the 3 main extensions (ssh, docker, wsl)" }, 177 | "common.nodeArch": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "What architecture of node is running. i.e. arm or x86. On the web it will just say web" }, 178 | 179 | "elixir_ls.elixir_version": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Elixir version"}, 180 | "elixir_ls.otp_release": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "OTP release"}, 181 | "elixir_ls.erts_version": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "OTP version"}, 182 | "elixir_ls.mix_env": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Mix env setting"}, 183 | "elixir_ls.mix_target": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Mix target setting"} 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "commonjs", 5 | "lib": ["ES2020"], 6 | "outDir": "out", 7 | "sourceMap": true, 8 | "strict": true 9 | }, 10 | "exclude": ["node_modules", ".vscode-test", "elixir-ls", "elixir-ls-release"] 11 | } 12 | --------------------------------------------------------------------------------