├── .eslintignore ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── 1-bug-report.yml │ └── 2-feature-request.yml └── workflows │ ├── codeql-analysis.yml │ ├── deploy.yml │ ├── upgrade-tree-sitter.yml │ └── verify.yml ├── .gitignore ├── .npmrc ├── .prettierrc ├── .tool-versions ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── codecov.yml ├── docs ├── development-guide.md └── releasing.md ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── renovate.json ├── scripts ├── release-client.sh ├── release-server.sh ├── tag-release.inc └── upgrade-tree-sitter.sh ├── server ├── CHANGELOG.md ├── README.md ├── package.json ├── parser.info ├── src │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ ├── analyzer.test.ts.snap │ │ │ └── server.test.ts.snap │ │ ├── analyzer.test.ts │ │ ├── config.test.ts │ │ ├── executables.test.ts │ │ ├── server.test.ts │ │ └── snippets.test.ts │ ├── analyser.ts │ ├── builtins.ts │ ├── cli.ts │ ├── config.ts │ ├── executables.ts │ ├── get-options.sh │ ├── parser.ts │ ├── reserved-words.ts │ ├── server.ts │ ├── shellcheck │ │ ├── __tests__ │ │ │ ├── config.test.ts │ │ │ ├── directive.test.ts │ │ │ └── index.test.ts │ │ ├── config.ts │ │ ├── directive.ts │ │ ├── index.ts │ │ └── types.ts │ ├── shfmt │ │ ├── __tests__ │ │ │ └── index.test.ts │ │ └── index.ts │ ├── snippets.ts │ ├── types.ts │ └── util │ │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ ├── sh.test.ts.snap │ │ │ └── sourcing.test.ts.snap │ │ ├── array.test.ts │ │ ├── logger.test.ts │ │ ├── sh.test.ts │ │ ├── shebang.test.ts │ │ └── sourcing.test.ts │ │ ├── array.ts │ │ ├── async.ts │ │ ├── declarations.ts │ │ ├── discriminate.ts │ │ ├── fs.ts │ │ ├── logger.ts │ │ ├── lsp.ts │ │ ├── platform.ts │ │ ├── sh.ts │ │ ├── shebang.ts │ │ ├── sourcing.ts │ │ └── tree-sitter.ts ├── tree-sitter-bash.wasm └── tsconfig.json ├── testing ├── executables │ ├── iam-executable │ ├── iam-not-executable │ └── sub-folder │ │ └── iam-executable-in-sub-folder ├── fixtures.ts ├── fixtures │ ├── basic-zsh.zsh │ ├── broken-symlink.sh │ ├── comment-doc-on-hover.sh │ ├── crash.zsh │ ├── extension │ ├── extension.inc │ ├── install.sh │ ├── issue101.sh │ ├── issue206.sh │ ├── just-a-folder.sh │ │ └── README.md │ ├── missing-node.sh │ ├── not-a-shell-script.sh │ ├── not-a-shell-script2.sh │ ├── options.sh │ ├── override-executable-symbol.sh │ ├── parse-problems.sh │ ├── renaming-read.sh │ ├── renaming.sh │ ├── scope.sh │ ├── shellcheck │ │ ├── shell-directive.bash │ │ ├── source.sh │ │ └── sourced.sh │ ├── shfmt-editorconfig │ │ ├── no-shfmt-properties │ │ │ └── .editorconfig │ │ ├── shfmt-properties-false │ │ │ └── .editorconfig │ │ └── shfmt-properties │ │ │ └── .editorconfig │ ├── shfmt.sh │ ├── sourcing.sh │ └── sourcing2.sh └── mocks.ts ├── tsconfig.eslint.json ├── tsconfig.json └── vscode-client ├── .npmrc ├── .vscodeignore ├── CHANGELOG.md ├── README.md ├── __tests__ └── config.test.ts ├── assets └── bash-logo.png ├── package.json ├── pnpm-lock.yaml ├── src ├── extension.ts └── server.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | **/out 2 | **/node_modules 3 | !.eslintrc.js 4 | coverage 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | plugins: [ 4 | '@typescript-eslint', 5 | 'jest', 6 | 'simple-import-sort', 7 | 'sort-class-members', 8 | 'prettier', 9 | ], 10 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], 11 | parserOptions: { 12 | ecmaFeatures: { 13 | jsx: true, 14 | }, 15 | ecmaVersion: 2018, 16 | sourceType: 'module', 17 | project: './tsconfig.eslint.json', 18 | }, 19 | rules: { 20 | 'prettier/prettier': ['error'], 21 | '@typescript-eslint/no-unused-vars': [ 22 | 'error', 23 | { 24 | vars: 'all', 25 | args: 'none', 26 | argsIgnorePattern: '^_', 27 | ignoreRestSiblings: true, 28 | }, 29 | ], 30 | 'no-console': ['error'], 31 | 'prefer-destructuring': [ 32 | 'error', 33 | { 34 | array: false, 35 | object: true, 36 | }, 37 | ], 38 | 'prefer-const': 'error', 39 | 'prefer-template': 'error', 40 | 'simple-import-sort/imports': 'error', 41 | 'simple-import-sort/exports': 'error', 42 | 'object-shorthand': 'error', 43 | 'sort-class-members/sort-class-members': [ 44 | 'error', 45 | { 46 | order: [ 47 | '[properties]', 48 | '[conventional-private-properties]', 49 | 'constructor', 50 | '[static-properties]', 51 | '[static-methods]', 52 | '[methods]', 53 | '[conventional-private-methods]', 54 | ], 55 | accessorPairPositioning: 'getThenSet', 56 | }, 57 | ], 58 | 59 | '@typescript-eslint/explicit-function-return-type': 'off', 60 | '@typescript-eslint/explicit-member-accessibility': 'off', 61 | '@typescript-eslint/no-explicit-any': 'off', 62 | '@typescript-eslint/no-use-before-define': 'off', 63 | '@typescript-eslint/prefer-interface': 'off', 64 | '@typescript-eslint/no-var-requires': 'off', 65 | 'no-unused-vars': 'off', // replaced by @typescript-eslint/no-unused-vars 66 | }, 67 | env: { 68 | browser: false, 69 | node: true, 70 | es6: true, 71 | 'jest/globals': true, 72 | }, 73 | } 74 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug-report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug report 2 | description: Create a report to help us improve 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | Thank you for reporting an issue. 8 | 9 | Please fill in as much of the following form as you're able. 10 | - type: input 11 | attributes: 12 | label: Code editor 13 | description: | 14 | E.g. Eclipse, Emacs, Vim, Visual Studio Code, etc. 15 | - type: input 16 | attributes: 17 | label: Platform 18 | description: | 19 | E.g. Unix, Windows, OS X, ? 20 | - type: input 21 | attributes: 22 | label: Version 23 | description: Output of `bash-language-server -v` or the version of the extension/client you are using. 24 | - type: textarea 25 | attributes: 26 | label: What steps will reproduce the bug? 27 | description: Enter details about your bug, preferably a simple code snippet. 28 | - type: textarea 29 | attributes: 30 | label: How often does it reproduce? Is there a required condition? 31 | - type: textarea 32 | attributes: 33 | label: What is the expected behavior? 34 | - type: textarea 35 | attributes: 36 | label: What do you see instead? 37 | validations: 38 | required: true 39 | - type: textarea 40 | attributes: 41 | label: Additional information 42 | description: Tell us anything else you think we should know. An output from the language server logs would be great. 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-feature-request.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Feature request 2 | description: Suggest an idea for this project 3 | labels: [enhancement] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thank you for suggesting an idea to make Bash Language Server better. 9 | 10 | Please fill in as much of the following form as you're able. 11 | - type: textarea 12 | attributes: 13 | label: What is the problem this feature will solve? 14 | validations: 15 | required: true 16 | - type: textarea 17 | attributes: 18 | label: What is the feature you are proposing to solve the problem? 19 | validations: 20 | required: true 21 | - type: textarea 22 | attributes: 23 | label: What alternatives have you considered? 24 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL Code Scanning" 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | schedule: 9 | - cron: '0 0 * * MON' # every Monday 10 | 11 | jobs: 12 | CodeQL-Build: 13 | runs-on: ubuntu-latest 14 | 15 | permissions: 16 | security-events: write 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Initialize CodeQL 23 | uses: github/codeql-action/init@v3 24 | with: 25 | languages: javascript 26 | 27 | - name: Perform CodeQL Analysis 28 | uses: github/codeql-action/analyze@v3 29 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy server and extension 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Install shellcheck and shfmt (used for testing) 15 | run: sudo apt-get install -y shellcheck shfmt 16 | 17 | - uses: pnpm/action-setup@v4 18 | with: 19 | version: 9 20 | 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: 20 24 | registry-url: https://registry.npmjs.org/ 25 | cache: "pnpm" 26 | 27 | - run: | 28 | git config --local user.email "kenneth.skovhus@gmail.com" 29 | git config --local user.name "skovhus" 30 | name: Configure for pushing git tags 31 | 32 | - run: bash scripts/release-client.sh 33 | name: Deploy VS Code extension 34 | env: 35 | VSCE_TOKEN: ${{ secrets.VSCE_PERSONAL_ACCESS_TOKEN }} 36 | 37 | - run: bash scripts/release-server.sh 38 | name: Deploy server 39 | env: 40 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 41 | -------------------------------------------------------------------------------- /.github/workflows/upgrade-tree-sitter.yml: -------------------------------------------------------------------------------- 1 | name: Upgrade tree-sitter 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '0 12 * * 2' 7 | 8 | jobs: 9 | upgrade_tree_sitter: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - uses: pnpm/action-setup@v4 15 | with: 16 | version: 9 17 | 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 20 21 | cache: "pnpm" 22 | 23 | - run: pnpm install --frozen-lockfile 24 | 25 | - name: Upgrade tree-sitter wasm 26 | run: bash scripts/upgrade-tree-sitter.sh 27 | 28 | - name: Verify file changes 29 | uses: tj-actions/verify-changed-files@v17 30 | id: verify-changed-files 31 | with: 32 | files: | 33 | server/parser.info 34 | server/tree-sitter-bash.wasm 35 | 36 | - name: Create pull request 37 | if: steps.verify-changed-files.outputs.files_changed == 'true' 38 | uses: peter-evans/create-pull-request@v7 39 | with: 40 | add-paths: server 41 | title: Auto upgrade tree-sitter-bash parser 42 | commit-message: Auto upgrade tree-sitter-bash parser 43 | token: ${{ secrets.GH_TOKEN }} 44 | -------------------------------------------------------------------------------- /.github/workflows/verify.yml: -------------------------------------------------------------------------------- 1 | name: Verify changes 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | verify: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [18.x, 20.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Install shellcheck and shfmt (used for testing) 18 | run: sudo apt-get install -y shellcheck shfmt 19 | 20 | - uses: pnpm/action-setup@v4 21 | with: 22 | version: 9 23 | 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v4 26 | with: 27 | cache: "pnpm" 28 | node-version: ${{ matrix.node-version }} 29 | 30 | - run: pnpm install --frozen-lockfile 31 | 32 | - name: Verify changes 33 | run: pnpm verify:bail 34 | 35 | - name: Publish coverage to codecov.io 36 | uses: codecov/codecov-action@v5 37 | if: success() && matrix.node-version == '20.x' 38 | with: 39 | token: ${{ secrets.CODECOV_TOKEN }} 40 | files: ./codecov.yml 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build output 2 | vscode-client/out 3 | server/out 4 | 5 | # Linter cache 6 | coverage/ 7 | .eslintcache/ 8 | .eslintcache 9 | 10 | # OS related 11 | .DS_Store 12 | *.log 13 | 14 | # NPM cache 15 | node_modules/ 16 | package-lock.json 17 | 18 | # YARN cache 19 | .yarn/ 20 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact = true 2 | hoist = false 3 | link-workspace-packages = false 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 90, 5 | "semi": false 6 | } 7 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 18.20.8 2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // https://github.com/microsoft/vscode-extension-samples/blob/e1ecdaec8974b938e7a92589faa233e1691d251f/lsp-sample/.vscode/launch.json 3 | { 4 | "version": "0.2.0", 5 | "configurations": [ 6 | { 7 | "name": "Launch Client", 8 | "type": "extensionHost", 9 | "request": "launch", 10 | "runtimeExecutable": "${execPath}", 11 | "args": [ 12 | "--extensionDevelopmentPath=${workspaceRoot}/vscode-client" 13 | ], 14 | "outFiles": [ 15 | "${workspaceRoot}/vscode-client/out/**/*.js" 16 | ], 17 | "preLaunchTask": { 18 | "type": "npm", 19 | "script": "watch" 20 | } 21 | }, 22 | { 23 | "type": "node", 24 | "request": "attach", 25 | "name": "Attach to Server", 26 | "port": 6009, 27 | "restart": true, 28 | "outFiles": ["${workspaceRoot}/server/out/**/*.js"] 29 | }, 30 | { 31 | "name": "Language Server E2E Test", 32 | "type": "extensionHost", 33 | "request": "launch", 34 | "runtimeExecutable": "${execPath}", 35 | "args": [ 36 | "--extensionDevelopmentPath=${workspaceRoot}", 37 | "--extensionTestsPath=${workspaceRoot}/vscode-client/out/test/index", 38 | "${workspaceRoot}/vscode-client/testFixture" 39 | ], 40 | "outFiles": ["${workspaceRoot}/vscode-client/out/test/**/*.js"] 41 | } 42 | ], 43 | "compounds": [ 44 | { 45 | "name": "Client + Server", 46 | "configurations": ["Launch Client", "Attach to Server"] 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "server/out": true 4 | }, 5 | "search.exclude": { 6 | "out": true 7 | }, 8 | "typescript.tsdk": "./node_modules/typescript/lib", 9 | "typescript.tsc.autoDetect": "off", 10 | "editor.codeActionsOnSave": { 11 | "source.fixAll.eslint": "explicit" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "watch", 7 | "problemMatcher": "$tsc-watch", 8 | "isBackground": true, 9 | "presentation": { 10 | "panel": "dedicated", 11 | "reveal": "never" 12 | }, 13 | "group": { 14 | "kind": "build", 15 | "isDefault": true 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Bash Language Server 2 | 3 | First off, thanks for taking the time to contribute! ❤️ 4 | 5 | All types of contributions are encouraged and valued. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉 6 | 7 | > And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about: 8 | > 9 | > - Star the project 10 | > - Tweet about it 11 | > - Refer this project in your project's readme 12 | > - Mention the project at local meetups and tell your friends/colleagues 13 | 14 | ## I Have a Question 15 | 16 | > If you want to ask a question, we assume that you have read the available [Documentation](https://github.com/bash-lsp/bash-language-server/tree/main/docs). 17 | 18 | Before you ask a question, it is best to search for existing [Issues](https://github.com/bash-lsp/bash-language-server/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first. 19 | 20 | If you then still feel the need to ask a question and need clarification, we recommend the following: 21 | 22 | - Open an [Issue](https://github.com/bash-lsp/bash-language-server/issues/new). 23 | - Provide as much context as you can about what you're running into. 24 | - Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant. 25 | 26 | We will then take care of the issue as soon as possible. 27 | 28 | ## I Want To Contribute 29 | 30 | > ### Legal Notice 31 | > 32 | > When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license. 33 | 34 | ### Reporting Bugs 35 | 36 | #### Before Submitting a Bug Report 37 | 38 | A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible. 39 | 40 | - Make sure that you are using the latest version. 41 | - Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](https://github.com/bash-lsp/bash-language-server/tree/main/docs). If you are looking for support, you might want to check [this section](#i-have-a-question)). 42 | - To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/bash-lsp/bash-language-server/issues?q=label%3Abug). 43 | - Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue. 44 | - Collect information about the bug: 45 | - Stack trace (Traceback) 46 | - OS, Platform and Version (Windows, Linux, macOS, x86, ARM) 47 | - Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant. 48 | - Possibly your input and the output 49 | - Can you reliably reproduce the issue? And can you also reproduce it with older versions? 50 | 51 | #### How Do I Submit a Good Bug Report? 52 | 53 | > You must never report security related issues, vulnerabilities or bugs including sensitive information to the issue tracker, or elsewhere in public. Instead sensitive bugs must be sent by email to . 54 | 55 | We use GitHub issues to track bugs and errors. If you run into an issue with the project: 56 | 57 | - Open an [Issue](https://github.com/bash-lsp/bash-language-server/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.) 58 | - Explain the behavior you would expect and the actual behavior. 59 | - Please provide as much context as possible and describe the _reproduction steps_ that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case. 60 | - Provide the information you collected in the previous section. 61 | 62 | Once it's filed: 63 | 64 | - The issue will be left to be [picked up by someone in the open source community](#your-first-code-contribution). 65 | 66 | ### Suggesting Enhancements 67 | 68 | This section guides you through submitting an enhancement suggestion for Bash Language Server, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions. 69 | 70 | #### Before Submitting an Enhancement 71 | 72 | - Make sure that you are using the latest version. 73 | - Read the [documentation](https://github.com/bash-lsp/bash-language-server/tree/main/docs) carefully and find out if the functionality is already covered, maybe by an individual configuration. 74 | - Perform a [search](https://github.com/bash-lsp/bash-language-server/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. 75 | - Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library. 76 | 77 | #### How Do I Submit a Good Enhancement Suggestion? 78 | 79 | Enhancement suggestions are tracked as [GitHub issues](https://github.com/bash-lsp/bash-language-server/issues). 80 | 81 | - Use a **clear and descriptive title** for the issue to identify the suggestion. 82 | - Provide a **step-by-step description of the suggested enhancement** in as many details as possible. 83 | - **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you. 84 | - You may want to **include screenshots and animated GIFs** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux. 85 | - **Explain why this enhancement would be useful** to most Bash Language Server users. You may also want to point out the other projects that solved it better and which could serve as inspiration. 86 | 87 | ### Your First Code Contribution 88 | 89 | Please follow steps mentioned in the [development guide](https://github.com/bash-lsp/bash-language-server/tree/main/docs/development-guide.md) 90 | 91 | ## Join The Project Team 92 | 93 | It would be great to have more core contributors! Please [reach out](kenneth.skovhus@gmail.com) if you want to help out. 94 | 95 | ## Attribution 96 | 97 | This guide is based on the **contributing-gen**. [Make your own](https://github.com/bttger/contributing-gen)! 98 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Mads Hartmann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bash Language Server 2 | 3 | Bash language server that brings an IDE-like experience for bash scripts to most editors. This is based on the [Tree Sitter parser][tree-sitter-bash] and supports [explainshell][explainshell], [shellcheck][shellcheck] and [shfmt][shfmt]. 4 | 5 | Documentation around configuration variables can be found in the [config.ts](https://github.com/bash-lsp/bash-language-server/blob/main/server/src/config.ts) file. 6 | 7 | ## Features 8 | 9 | - Jump to declaration 10 | - Find references 11 | - Code Outline & Show Symbols 12 | - Highlight occurrences 13 | - Code completion 14 | - Simple diagnostics reporting 15 | - Documentation for symbols on hover 16 | - Workspace symbols 17 | - Rename symbol 18 | - Format document 19 | 20 | To be implemented: 21 | 22 | - Better jump to declaration and find references based on scope 23 | 24 | ## Installation 25 | 26 | ### Dependencies 27 | 28 | As a dependency, we recommend that you first install [shellcheck][shellcheck] to enable linting: 29 | https://github.com/koalaman/shellcheck#installing . If `shellcheck` is installed, 30 | bash-language-server will automatically call it to provide linting and code analysis each time the 31 | file is updated (with debounce time of 500ms). 32 | 33 | If you want your shell scripts to be formatted consistently, you can install [shfmt][shfmt]. If 34 | `shfmt` is installed then your documents will be formatted whenever you take the 'format document' 35 | action. In most editors this can be configured to happen automatically when files are saved. 36 | 37 | ### Bash language server 38 | 39 | Usually you want to install a client for your editor (see the section below). 40 | 41 | But if you want to install the server binary (for examples for editors, like helix, where a generic LSP client is built in), you can install from npm registry as: 42 | 43 | ```bash 44 | npm i -g bash-language-server 45 | ``` 46 | 47 | Alternatively, bash-language-server may also be distributed directly by your Linux distro, for example on Fedora based distros: 48 | 49 | ```bash 50 | dnf install -y nodejs-bash-language-server 51 | ``` 52 | 53 | Or on Ubuntu with snap: 54 | 55 | ```bash 56 | sudo snap install bash-language-server --classic 57 | ``` 58 | 59 | To verify that everything is working: 60 | 61 | ```bash 62 | bash-language-server --help 63 | ``` 64 | 65 | If you encounter installation errors, ensure you have node version 16 or newer (`node --version`). 66 | 67 | ### Clients 68 | 69 | The following editors and IDEs have available clients: 70 | 71 | - Atom ([ide-bash][ide-bash]) 72 | - Eclipse ([ShellWax](https://marketplace.eclipse.org/content/shellwax)) 73 | - Emacs ([see below](#emacs)) 74 | - [Helix](https://helix-editor.com/) (built-in support) 75 | - JupyterLab ([jupyterlab-lsp][jupyterlab-lsp]) 76 | - Neovim ([see below](#neovim)) 77 | - Sublime Text ([LSP-bash][sublime-text-lsp]) 78 | - Vim ([see below](#vim)) 79 | - Visual Studio Code ([Bash IDE][vscode-marketplace]) 80 | - [Oni](https://github.com/onivim/oni) ([see below](#oni)) 81 | 82 | #### Vim 83 | 84 | For Vim 8 or later install the plugin [prabirshrestha/vim-lsp][vim-lsp] and add the following configuration to `.vimrc`: 85 | 86 | ```vim 87 | if executable('bash-language-server') 88 | au User lsp_setup call lsp#register_server({ 89 | \ 'name': 'bash-language-server', 90 | \ 'cmd': {server_info->['bash-language-server', 'start']}, 91 | \ 'allowlist': ['sh', 'bash'], 92 | \ }) 93 | endif 94 | ``` 95 | 96 | For Vim 8 or Neovim using [YouCompleteMe](https://github.com/ycm-core/YouCompleteMe), add the following to `.vimrc`: 97 | 98 | ```vim 99 | let g:ycm_language_server = 100 | \ [ 101 | \ { 102 | \ 'name': 'bash', 103 | \ 'cmdline': [ 'bash-language-server', 'start' ], 104 | \ 'filetypes': [ 'sh' ], 105 | \ } 106 | \ ] 107 | ``` 108 | 109 | For Vim 8 or Neovim using [neoclide/coc.nvim][coc.nvim], according to [it's Wiki article](https://github.com/neoclide/coc.nvim/wiki/Language-servers#bash), add the following to your `coc-settings.json`: 110 | 111 | ```jsonc 112 | "languageserver": { 113 | "bash": { 114 | "command": "bash-language-server", 115 | "args": ["start"], 116 | "filetypes": ["sh"], 117 | "ignoredRootPaths": ["~"] 118 | } 119 | } 120 | ``` 121 | 122 | For Vim 8 or NeoVim using [dense-analysis/ale][vim-ale] add the following 123 | configuration to your `.vimrc`: 124 | 125 | ```vim 126 | let g:ale_linters = { 127 | \ 'sh': ['language_server'], 128 | \ } 129 | ``` 130 | 131 | For Vim8/NeoVim v0.5 using [jayli/vim-easycomplete](https://github.com/jayli/vim-easycomplete). Execute `:InstallLspServer sh` and config nothing. Maybe it's the easiest way to use bash-language-server in vim/nvim. 132 | 133 | #### Neovim 134 | 135 | For Neovim 0.11+ with [nvim-lspconfig](https://github.com/neovim/nvim-lspconfig) 136 | 137 | ```lua 138 | vim.lsp.enable 'bashls' 139 | ``` 140 | For Neovim 0.11+ without plugins 141 | 142 | ```lua 143 | vim.lsp.config.bashls = { 144 | cmd = { 'bash-language-server', 'start' }, 145 | filetypes = { 'bash', 'sh' } 146 | } 147 | vim.lsp.enable 'bashls' 148 | ``` 149 | 150 | For Neovim 0.10 or lower with [nvim-lspconfig](https://github.com/neovim/nvim-lspconfig) 151 | ```lua 152 | require 'lspconfig'.bashls.setup {} 153 | ``` 154 | 155 | #### Oni 156 | 157 | On the config file (`File -> Preferences -> Edit Oni config`) add the following configuration: 158 | 159 | ```javascript 160 | "language.bash.languageServer.command": "bash-language-server", 161 | "language.bash.languageServer.arguments": ["start"], 162 | ``` 163 | 164 | #### Emacs 165 | 166 | [Lsp-mode](https://github.com/emacs-lsp/lsp-mode) has a built-in client, can be installed by `use-package`. 167 | Add the configuration to your `.emacs.d/init.el` 168 | 169 | ```emacs-lisp 170 | (use-package lsp-mode 171 | :commands lsp 172 | :hook 173 | (sh-mode . lsp)) 174 | ``` 175 | 176 | Using the built-in `eglot` lsp mode: 177 | 178 | ```emacs-lisp 179 | (use-package eglot 180 | :config 181 | (add-to-list 'eglot-server-programs '((sh-mode bash-ts-mode) . ("bash-language-server" "start"))) 182 | 183 | :hook 184 | (sh-mode . eglot-ensure) 185 | (bash-ts-mode . eglot-ensure)) 186 | ``` 187 | 188 | ## `shfmt` integration 189 | 190 | The indentation used by `shfmt` is whatever has been configured for the current editor session, so 191 | there is no `shfmt`-specific configuration variable for this. If your editor is configured for 192 | two-space indents then that's what it will use. If you're using tabs for indentation then `shfmt` 193 | will use that. 194 | 195 | The `shfmt` integration also supports configuration via `.editorconfig`. If any `shfmt`-specific 196 | configuration properties are found in `.editorconfig` then the config in `.editorconfig` will be 197 | used and the language server config will be ignored. This follows `shfmt`'s approach of using either 198 | `.editorconfig` or command line flags, but not both. Note that only `shfmt`-specific configuration 199 | properties are read from `.editorconfig` - indentation preferences are still provided by the editor, 200 | so to format using the indentation specified in `.editorconfig` make sure your editor is also 201 | configured to read `.editorconfig`. It is possible to disable `.editorconfig` support and always use 202 | the language server config by setting the "Ignore Editorconfig" configuration variable. 203 | 204 | ## Logging 205 | 206 | The minimum logging level for the server can be adjusted using the `BASH_IDE_LOG_LEVEL` environment variable 207 | and through the general [workspace configuration](https://github.com/bash-lsp/bash-language-server/blob/main/server/src/config.ts). 208 | 209 | ## Development Guide 210 | 211 | Please see [docs/development-guide][dev-guide] for more information. 212 | 213 | [tree-sitter]: https://github.com/tree-sitter/tree-sitter 214 | [tree-sitter-bash]: https://github.com/tree-sitter/tree-sitter-bash 215 | [vscode-marketplace]: https://marketplace.visualstudio.com/items?itemName=mads-hartmann.bash-ide-vscode 216 | [dev-guide]: https://github.com/bash-lsp/bash-language-server/blob/master/docs/development-guide.md 217 | [ide-bash]: https://atom.io/packages/ide-bash 218 | [sublime-text-lsp]: https://packagecontrol.io/packages/LSP-bash 219 | [explainshell]: https://explainshell.com/ 220 | [shellcheck]: https://www.shellcheck.net/ 221 | [shfmt]: https://github.com/mvdan/sh#shfmt 222 | [languageclient-neovim]: https://github.com/autozimu/LanguageClient-neovim 223 | [nvim-lspconfig]: https://github.com/neovim/nvim-lspconfig 224 | [vim-lsp]: https://github.com/prabirshrestha/vim-lsp 225 | [vim-ale]: https://github.com/dense-analysis/ale 226 | [coc.nvim]: https://github.com/neoclide/coc.nvim 227 | [jupyterlab-lsp]: https://github.com/krassowski/jupyterlab-lsp 228 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: on 4 | patch: off 5 | 6 | project: 7 | default: 8 | threshold: 1% 9 | -------------------------------------------------------------------------------- /docs/development-guide.md: -------------------------------------------------------------------------------- 1 | # Development guide 2 | 3 | There are two moving parts. 4 | 5 | - **Server**: A node server written in Typescript that implements the 6 | [Language Server Protocol (LSP)][lsp]. 7 | 8 | **Client**: A Visual Studio Code (vscode) extension which wraps the LSP server. 9 | 10 | The project has a root `package.json` file which is really just there for 11 | convenience - it proxies to the `package.json` files in the `vscode-client` and 12 | `server` folders. 13 | 14 | ## Prerequisites 15 | 16 | This guide presumes you have the following dependencies installed: 17 | 18 | - [`pnpm`][pnpm]. 19 | - [`node`][node] (v16 or newer) 20 | 21 | ## Initial setup 22 | 23 | Run the following in the root of the project 24 | 25 | ``` 26 | pnpm install 27 | ``` 28 | 29 | This uses the `postinstall` hook to install the dependencies in each of the 30 | sub-projects. 31 | 32 | To make sure that everything is configured correctly run the following command 33 | to compile both the client and the server once 34 | 35 | ``` 36 | pnpm compile 37 | ``` 38 | 39 | Now, depending on which part you want to work on follow the relevant section 40 | below. 41 | 42 | ## Development Tools 43 | 44 | To support a good develop workflow we set up [eslint][eslint], [Prettier][prettier] and integration tests using [Jest][jest]: 45 | 46 | pnpm verify # (runs lint, prettier and tests) 47 | pnpm lint 48 | pnpm test 49 | pnpm test:coverage 50 | 51 | ## Working on the client 52 | 53 | ### Visual Studio Code 54 | 55 | Working on the client is simply a matter of starting vscode and using the Debug 56 | View to launch the `Launch Client` task. This will open a new vscode window with the 57 | extension loaded. It also looks for changes to your client code and recompiles 58 | it whenever you save your changes. 59 | 60 | ### Atom 61 | 62 | See the [ide-bash][ide-bash] package for Atom. Due to how Atom packages are 63 | published the client lives in a separate repository. 64 | 65 | ## Working on the server (VS Code) 66 | 67 | As the server is embedded into the VS Code client, you can link any server 68 | changes into the local installation of your VS Code client by running this once: 69 | 70 | ``` 71 | pnpm link-server 72 | ``` 73 | 74 | After that follow the steps above to work on the client. 75 | 76 | ## Working on the server (standalone) 77 | 78 | If you are working on the server outside of VS Code, then simply compile 79 | and install the server globally whenever you've made a change, and then 80 | reload your vscode window to re-launch the server. 81 | 82 | ``` 83 | pnpm reinstall-server 84 | ``` 85 | 86 | If you for some reason cannot get access to logs through the client, 87 | then you can hack the `server/util/logger` with: 88 | 89 | ```typescript 90 | const fs = require('fs') 91 | const util = require('util') 92 | const log_file = fs.createWriteStream(`/tmp/bash-language-server-debug.log`, { 93 | flags: 'w', 94 | }) 95 | 96 | // inside log function 97 | log_file.write(`${severity} ${util.format(message)}\n`) 98 | ``` 99 | 100 | ## Performance 101 | 102 | To analyze the performance of the extension or server using the Chrome inspector: 103 | 104 | 1. In Code start debugging "Run -> Start debugging" 105 | 2. Open `chrome://inspect` in Chrome and ensure the port `localhost:6009` is added 106 | 107 | [lsp]: https://microsoft.github.io/language-server-protocol/ 108 | [ide-bash]: https://github.com/bash-lsp/ide-bash 109 | [jest]: https://facebook.github.io/jest/ 110 | [prettier]: https://prettier.io/ 111 | [eslint]: https://eslint.org/ 112 | [pnpm]: https://pnpm.io/installation 113 | [node]: https://nodejs.org/en/download/ 114 | -------------------------------------------------------------------------------- /docs/releasing.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | ## Client 4 | 5 | To release a new version of the vscode extension 6 | 7 | - Bump the version in `vscode-client/package.json` 8 | - Update `vscode-client/CHANGELOG.md` 9 | - Merge to main branch 10 | 11 | ## Server 12 | 13 | To release a new version of the server 14 | 15 | - Bump the version in `server/package.json` 16 | - Update `server/CHANGELOG.md` 17 | - Merge to main branch 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "clean": "rm -rf *.log */*.log */out node_modules vscode-client/node_modules server/node_modules", 5 | "preinstall": "npx only-allow pnpm", 6 | "compile": "tsc -b", 7 | "watch": "tsc -b -w", 8 | "lint": "pnpm lint:bail --fix", 9 | "lint:bail": "eslint . --ext js,ts,tsx --cache", 10 | "test": "jest --runInBand", 11 | "test:coverage": "pnpm run test --coverage", 12 | "test:watch": "pnpm run test --watch", 13 | "verify": "pnpm lint && pnpm compile && pnpm run test", 14 | "verify:bail": "pnpm lint:bail && pnpm compile && pnpm test:coverage", 15 | "reinstall-server": "npm uninstall -g bash-language-server && pnpm compile && npm i -g ./server", 16 | "link-server": "npm uninstall -g bash-language-server && pnpm compile && pnpm unlink --dir=vscode-client bash-language-server && cd server && pnpm link . && cd ../vscode-client && pnpm link bash-language-server", 17 | "postinstall": "pnpm --dir=vscode-client install --ignore-workspace" 18 | }, 19 | "devDependencies": { 20 | "@types/jest": "29.5.14", 21 | "@types/node": "18.19.110", 22 | "@typescript-eslint/eslint-plugin": "7.18.0", 23 | "@typescript-eslint/parser": "7.18.0", 24 | "cross-spawn": ">=7.0.5", 25 | "eslint": "8.57.1", 26 | "eslint-config-prettier": "9.1.0", 27 | "eslint-plugin-jest": "28.12.0", 28 | "eslint-plugin-prettier": "4.2.1", 29 | "eslint-plugin-simple-import-sort": "12.1.1", 30 | "eslint-plugin-sort-class-members": "1.21.0", 31 | "jest": "29.7.0", 32 | "prettier": "2.8.8", 33 | "ts-jest": "29.3.4", 34 | "typescript": "5.6.3", 35 | "vscode-languageserver": "8.0.2", 36 | "vscode-languageserver-textdocument": "1.0.12" 37 | }, 38 | "resolutions": { 39 | "@types/vscode": "1.100.0" 40 | }, 41 | "engines": { 42 | "node": ">=16", 43 | "pnpm": ">=9.x" 44 | }, 45 | "jest": { 46 | "preset": "ts-jest", 47 | "testEnvironment": "node", 48 | "clearMocks": true, 49 | "moduleFileExtensions": [ 50 | "js", 51 | "json", 52 | "node", 53 | "ts" 54 | ], 55 | "modulePathIgnorePatterns": [ 56 | "/server/out", 57 | "/vscode-client/out" 58 | ], 59 | "transform": { 60 | "\\.ts$": [ 61 | "ts-jest", 62 | { 63 | "tsconfig": "./server/tsconfig.json" 64 | } 65 | ] 66 | }, 67 | "testMatch": [ 68 | "/**/__tests__/*.ts" 69 | ], 70 | "collectCoverageFrom": [ 71 | "**/*.ts", 72 | "!**/__test__/*", 73 | "!testing/*" 74 | ], 75 | "coverageReporters": [ 76 | "text-summary", 77 | "lcov", 78 | "html" 79 | ] 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'server' 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "packageRules": [ 4 | { 5 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 6 | "automerge": true 7 | } 8 | ], 9 | "ignoreDeps": ["bash-language-server"] 10 | } 11 | -------------------------------------------------------------------------------- /scripts/release-client.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | source ./scripts/tag-release.inc 6 | 7 | version=$(cat vscode-client/package.json | jq -r .version) 8 | tag="vscode-client-${version}" 9 | 10 | pnpm clean 11 | pnpm install 12 | pnpm verify:bail 13 | 14 | cd vscode-client 15 | 16 | npx @vscode/vsce@2.26.0 publish --skip-duplicate -p $VSCE_TOKEN 17 | tagRelease $tag || echo "Tag update failed, likely already exists" 18 | -------------------------------------------------------------------------------- /scripts/release-server.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | source ./scripts/tag-release.inc 6 | 7 | version=$(cat server/package.json | jq -r .version) 8 | tag="server-${version}" 9 | 10 | publishedVersion=$(pnpm info bash-language-server --json | jq -r .\"dist-tags\".latest) 11 | 12 | if [ "$version" = "$publishedVersion" ]; then 13 | echo "Newest server version is already deployed." 14 | exit 0 15 | fi 16 | 17 | pnpm clean 18 | pnpm install 19 | pnpm verify:bail 20 | 21 | cd server 22 | npm publish 23 | # npm publish --tag beta # for releasing beta versions 24 | tagRelease $tag 25 | -------------------------------------------------------------------------------- /scripts/tag-release.inc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | function tagRelease { 6 | tag=$1 7 | git tag -a "${tag}" -m "Release ${tag}" 8 | git push origin "${tag}" 9 | } 10 | -------------------------------------------------------------------------------- /scripts/upgrade-tree-sitter.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euox pipefail 4 | 5 | cd server 6 | pnpm add web-tree-sitter 7 | pnpm add --save-dev tree-sitter-cli https://github.com/tree-sitter/tree-sitter-bash 8 | npx tree-sitter build --wasm node_modules/tree-sitter-bash 9 | 10 | curl 'https://api.github.com/repos/tree-sitter/tree-sitter-bash/commits/master' | jq .commit.url > parser.info 11 | echo "tree-sitter-cli $(cat package.json | jq '.devDependencies["tree-sitter-cli"]')" >> parser.info 12 | 13 | pnpm remove tree-sitter-cli tree-sitter-bash 14 | 15 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # Bash Language Server 2 | 3 | This folder holds the node server written in Typescript that implements the Language Server Protocol (LSP). 4 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bash-language-server", 3 | "description": "A language server for Bash", 4 | "author": "Mads Hartmann", 5 | "license": "MIT", 6 | "version": "5.6.0", 7 | "main": "./out/server.js", 8 | "typings": "./out/server.d.ts", 9 | "bin": { 10 | "bash-language-server": "./out/cli.js" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/bash-lsp/bash-language-server" 15 | }, 16 | "engines": { 17 | "node": ">=16" 18 | }, 19 | "dependencies": { 20 | "editorconfig": "2.0.1", 21 | "fast-glob": "3.3.3", 22 | "fuzzy-search": "3.2.1", 23 | "node-fetch": "2.7.0", 24 | "turndown": "7.2.0", 25 | "vscode-languageserver": "8.0.2", 26 | "vscode-languageserver-textdocument": "1.0.12", 27 | "web-tree-sitter": "0.24.5", 28 | "zod": "3.25.48" 29 | }, 30 | "scripts": { 31 | "prepublishOnly": "cd ../ && pnpm compile && cp README.md server/" 32 | }, 33 | "devDependencies": { 34 | "@types/fuzzy-search": "2.1.5", 35 | "@types/node-fetch": "2.6.12", 36 | "@types/turndown": "5.0.5", 37 | "@types/urijs": "1.19.25" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /server/parser.info: -------------------------------------------------------------------------------- 1 | "https://api.github.com/repos/tree-sitter/tree-sitter-bash/git/commits/c8713e50f0bd77d080832fc61ad128bc8f2934e9" 2 | tree-sitter-cli "0.23.0" 3 | -------------------------------------------------------------------------------- /server/src/__tests__/config.test.ts: -------------------------------------------------------------------------------- 1 | import { ConfigSchema, getConfigFromEnvironmentVariables } from '../config' 2 | import { LOG_LEVEL_ENV_VAR } from '../util/logger' 3 | 4 | describe('ConfigSchema', () => { 5 | it('returns a default', () => { 6 | expect(ConfigSchema.parse({})).toMatchInlineSnapshot(` 7 | { 8 | "backgroundAnalysisMaxFiles": 500, 9 | "enableSourceErrorDiagnostics": false, 10 | "explainshellEndpoint": "", 11 | "globPattern": "**/*@(.sh|.inc|.bash|.command)", 12 | "includeAllWorkspaceSymbols": false, 13 | "logLevel": "info", 14 | "shellcheckArguments": [], 15 | "shellcheckPath": "shellcheck", 16 | "shfmt": { 17 | "binaryNextLine": false, 18 | "caseIndent": false, 19 | "funcNextLine": false, 20 | "ignoreEditorconfig": false, 21 | "keepPadding": false, 22 | "languageDialect": "auto", 23 | "path": "shfmt", 24 | "simplifyCode": false, 25 | "spaceRedirects": false, 26 | }, 27 | } 28 | `) 29 | }) 30 | it('parses an object', () => { 31 | expect( 32 | ConfigSchema.parse({ 33 | backgroundAnalysisMaxFiles: 1, 34 | explainshellEndpoint: 'localhost:8080', 35 | globPattern: '**/*@(.sh)', 36 | includeAllWorkspaceSymbols: true, 37 | shellcheckArguments: ' -e SC2001 -e SC2002 ', 38 | shellcheckPath: '', 39 | shfmt: { 40 | binaryNextLine: true, 41 | caseIndent: true, 42 | funcNextLine: true, 43 | ignoreEditorconfig: true, 44 | keepPadding: true, 45 | languageDialect: 'posix', 46 | path: 'myshfmt', 47 | simplifyCode: true, 48 | spaceRedirects: true, 49 | }, 50 | }), 51 | ).toMatchInlineSnapshot(` 52 | { 53 | "backgroundAnalysisMaxFiles": 1, 54 | "enableSourceErrorDiagnostics": false, 55 | "explainshellEndpoint": "localhost:8080", 56 | "globPattern": "**/*@(.sh)", 57 | "includeAllWorkspaceSymbols": true, 58 | "logLevel": "info", 59 | "shellcheckArguments": [ 60 | "-e", 61 | "SC2001", 62 | "-e", 63 | "SC2002", 64 | ], 65 | "shellcheckPath": "", 66 | "shfmt": { 67 | "binaryNextLine": true, 68 | "caseIndent": true, 69 | "funcNextLine": true, 70 | "ignoreEditorconfig": true, 71 | "keepPadding": true, 72 | "languageDialect": "posix", 73 | "path": "myshfmt", 74 | "simplifyCode": true, 75 | "spaceRedirects": true, 76 | }, 77 | } 78 | `) 79 | }) 80 | 81 | it('allows shellcheckArguments to be an array', () => { 82 | expect( 83 | ConfigSchema.parse({ 84 | shellcheckArguments: [' -e ', 'SC2001', '-e', 'SC2002 '], 85 | }).shellcheckArguments, 86 | ).toEqual(['-e', 'SC2001', '-e', 'SC2002']) 87 | }) 88 | }) 89 | describe('getConfigFromEnvironmentVariables', () => { 90 | it('returns a default', () => { 91 | process.env = {} 92 | const { config } = getConfigFromEnvironmentVariables() 93 | expect(config).toMatchInlineSnapshot(` 94 | { 95 | "backgroundAnalysisMaxFiles": 500, 96 | "enableSourceErrorDiagnostics": false, 97 | "explainshellEndpoint": "", 98 | "globPattern": "**/*@(.sh|.inc|.bash|.command)", 99 | "includeAllWorkspaceSymbols": false, 100 | "logLevel": "info", 101 | "shellcheckArguments": [], 102 | "shellcheckPath": "shellcheck", 103 | "shfmt": { 104 | "binaryNextLine": false, 105 | "caseIndent": false, 106 | "funcNextLine": false, 107 | "ignoreEditorconfig": false, 108 | "keepPadding": false, 109 | "languageDialect": "auto", 110 | "path": "shfmt", 111 | "simplifyCode": false, 112 | "spaceRedirects": false, 113 | }, 114 | } 115 | `) 116 | }) 117 | it('preserves an empty string', () => { 118 | process.env = { 119 | SHELLCHECK_PATH: '', 120 | SHFMT_PATH: '', 121 | EXPLAINSHELL_ENDPOINT: '', 122 | } 123 | const { config } = getConfigFromEnvironmentVariables() 124 | expect(config).toMatchInlineSnapshot(` 125 | { 126 | "backgroundAnalysisMaxFiles": 500, 127 | "enableSourceErrorDiagnostics": false, 128 | "explainshellEndpoint": "", 129 | "globPattern": "**/*@(.sh|.inc|.bash|.command)", 130 | "includeAllWorkspaceSymbols": false, 131 | "logLevel": "info", 132 | "shellcheckArguments": [], 133 | "shellcheckPath": "", 134 | "shfmt": { 135 | "binaryNextLine": false, 136 | "caseIndent": false, 137 | "funcNextLine": false, 138 | "ignoreEditorconfig": false, 139 | "keepPadding": false, 140 | "languageDialect": "auto", 141 | "path": "", 142 | "simplifyCode": false, 143 | "spaceRedirects": false, 144 | }, 145 | } 146 | `) 147 | }) 148 | 149 | it('parses environment variables', () => { 150 | process.env = { 151 | SHELLCHECK_PATH: '/path/to/shellcheck', 152 | SHELLCHECK_ARGUMENTS: '-e SC2001', 153 | SHFMT_PATH: '/path/to/shfmt', 154 | SHFMT_CASE_INDENT: 'true', 155 | EXPLAINSHELL_ENDPOINT: 'localhost:8080', 156 | GLOB_PATTERN: '*.*', 157 | BACKGROUND_ANALYSIS_MAX_FILES: '1', 158 | [LOG_LEVEL_ENV_VAR]: 'error', 159 | } 160 | const { config } = getConfigFromEnvironmentVariables() 161 | expect(config).toMatchInlineSnapshot(` 162 | { 163 | "backgroundAnalysisMaxFiles": 1, 164 | "enableSourceErrorDiagnostics": false, 165 | "explainshellEndpoint": "localhost:8080", 166 | "globPattern": "*.*", 167 | "includeAllWorkspaceSymbols": false, 168 | "logLevel": "error", 169 | "shellcheckArguments": [ 170 | "-e", 171 | "SC2001", 172 | ], 173 | "shellcheckPath": "/path/to/shellcheck", 174 | "shfmt": { 175 | "binaryNextLine": false, 176 | "caseIndent": true, 177 | "funcNextLine": false, 178 | "ignoreEditorconfig": false, 179 | "keepPadding": false, 180 | "languageDialect": "auto", 181 | "path": "/path/to/shfmt", 182 | "simplifyCode": false, 183 | "spaceRedirects": false, 184 | }, 185 | } 186 | `) 187 | }) 188 | 189 | it('parses environment variable with excessive white space', () => { 190 | process.env = { 191 | SHELLCHECK_ARGUMENTS: ' -e SC2001 -e SC2002 ', 192 | } 193 | const result = getConfigFromEnvironmentVariables().config.shellcheckArguments 194 | expect(result).toEqual(['-e', 'SC2001', '-e', 'SC2002']) 195 | }) 196 | it('parses boolean environment variables', () => { 197 | process.env = { 198 | INCLUDE_ALL_WORKSPACE_SYMBOLS: 'true', 199 | } 200 | let result = getConfigFromEnvironmentVariables().config.includeAllWorkspaceSymbols 201 | expect(result).toEqual(true) 202 | 203 | process.env = { 204 | INCLUDE_ALL_WORKSPACE_SYMBOLS: '1', 205 | } 206 | result = getConfigFromEnvironmentVariables().config.includeAllWorkspaceSymbols 207 | expect(result).toEqual(true) 208 | 209 | process.env = { 210 | INCLUDE_ALL_WORKSPACE_SYMBOLS: '0', 211 | } 212 | result = getConfigFromEnvironmentVariables().config.includeAllWorkspaceSymbols 213 | expect(result).toEqual(false) 214 | 215 | process.env = { 216 | INCLUDE_ALL_WORKSPACE_SYMBOLS: 'false', 217 | } 218 | result = getConfigFromEnvironmentVariables().config.includeAllWorkspaceSymbols 219 | expect(result).toEqual(false) 220 | }) 221 | }) 222 | -------------------------------------------------------------------------------- /server/src/__tests__/executables.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | 3 | import Executables from '../executables' 4 | 5 | let executables: Executables 6 | 7 | beforeAll(async () => { 8 | executables = await Executables.fromPath( 9 | path.resolve(__dirname, '..', '..', '..', 'testing', 'executables'), 10 | ) 11 | }) 12 | 13 | describe('list', () => { 14 | it('finds executables on the PATH', async () => { 15 | const result = executables.list().find((x) => x === 'iam-executable') 16 | expect(result).toBeTruthy() 17 | }) 18 | 19 | it.skip('only considers files that have the executable bit set', async () => { 20 | const result = executables.list().find((x) => x === 'iam-not-executable') 21 | expect(result).toBeFalsy() 22 | }) 23 | 24 | it('only considers executable directly on the PATH', async () => { 25 | const result = executables.list().find((x) => x === 'iam-executable-in-sub-folder') 26 | expect(result).toBeFalsy() 27 | }) 28 | }) 29 | 30 | describe('isExecutableOnPATH', () => { 31 | it('looks at the PATH it has been initialized with', async () => { 32 | const result = executables.isExecutableOnPATH('ls') 33 | expect(result).toEqual(false) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /server/src/__tests__/snippets.test.ts: -------------------------------------------------------------------------------- 1 | import { SNIPPETS } from '../snippets' 2 | 3 | describe('snippets', () => { 4 | it('should have unique labels', () => { 5 | const labels = SNIPPETS.map((snippet) => snippet.label) 6 | const uniqueLabels = new Set(labels) 7 | expect(labels.length).toBe(uniqueLabels.size) 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /server/src/builtins.ts: -------------------------------------------------------------------------------- 1 | // You can generate this list by running `compgen -b` in a bash session 2 | export const LIST = [ 3 | '.', 4 | ':', 5 | '[', 6 | 'alias', 7 | 'bg', 8 | 'bind', 9 | 'break', 10 | 'builtin', 11 | 'caller', 12 | 'cd', 13 | 'command', 14 | 'compgen', 15 | 'compopt', 16 | 'complete', 17 | 'continue', 18 | 'declare', 19 | 'dirs', 20 | 'disown', 21 | 'echo', 22 | 'enable', 23 | 'eval', 24 | 'exec', 25 | 'exit', 26 | 'export', 27 | 'false', 28 | 'fc', 29 | 'fg', 30 | 'getopts', 31 | 'hash', 32 | 'help', 33 | 'history', 34 | 'jobs', 35 | 'kill', 36 | 'let', 37 | 'local', 38 | 'logout', 39 | 'popd', 40 | 'printf', 41 | 'pushd', 42 | 'pwd', 43 | 'read', 44 | 'readonly', 45 | 'return', 46 | 'set', 47 | 'shift', 48 | 'shopt', 49 | 'source', 50 | 'suspend', 51 | 'test', 52 | 'times', 53 | 'trap', 54 | 'true', 55 | 'type', 56 | 'typeset', 57 | 'ulimit', 58 | 'umask', 59 | 'unalias', 60 | 'unset', 61 | 'wait', 62 | ] 63 | 64 | const SET = new Set(LIST) 65 | 66 | export function isBuiltin(word: string): boolean { 67 | return SET.has(word) 68 | } 69 | -------------------------------------------------------------------------------- /server/src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable no-console */ 3 | import * as LSP from 'vscode-languageserver/node' 4 | 5 | import BashServer from './server' 6 | import { DEFAULT_LOG_LEVEL, LOG_LEVEL_ENV_VAR } from './util/logger' 7 | 8 | const packageJson = require('../package') 9 | 10 | const PADDING = 38 11 | 12 | const commandsAndFlags = { 13 | start: 'Start listening on stdin/stdout', 14 | '-h, --help': 'Display this help and exit', 15 | '-v, --version': 'Print the version and exit', 16 | } as const 17 | 18 | function printHelp() { 19 | console.log(`Usage: 20 | ${Object.entries(commandsAndFlags) 21 | .map( 22 | ([k, description]) => 23 | ` ${`bash-language-server ${k}`.padEnd(PADDING)} ${description}`, 24 | ) 25 | .join('\n')} 26 | 27 | Environment variables: 28 | ${LOG_LEVEL_ENV_VAR.padEnd(PADDING)} Set the log level (default: ${DEFAULT_LOG_LEVEL}) 29 | 30 | Further documentation: ${packageJson.repository.url}`) 31 | } 32 | 33 | export function runCli() { 34 | const args = process.argv.slice(2) 35 | 36 | const start = args.find((s) => s == 'start') 37 | const version = args.find((s) => s == '-v' || s == '--version') 38 | const help = args.find((s) => s == '-h' || s == '--help') 39 | 40 | if (start) { 41 | listen() 42 | } else if (version) { 43 | console.log(packageJson.version) 44 | } else if (help) { 45 | printHelp() 46 | } else { 47 | if (args.length > 0) { 48 | console.error(`Unknown command '${args.join(' ')}'.`) 49 | } 50 | printHelp() 51 | } 52 | } 53 | 54 | export function listen() { 55 | // Create a connection for the server. 56 | // The connection uses stdin/stdout for communication. 57 | const connection = LSP.createConnection( 58 | new LSP.StreamMessageReader(process.stdin), 59 | new LSP.StreamMessageWriter(process.stdout), 60 | ) 61 | 62 | connection.onInitialize( 63 | async (params: LSP.InitializeParams): Promise => { 64 | const server = await BashServer.initialize(connection, params) 65 | server.register(connection) 66 | return { 67 | capabilities: server.capabilities(), 68 | } 69 | }, 70 | ) 71 | 72 | connection.listen() 73 | } 74 | 75 | if (require.main === module) { 76 | runCli() 77 | } 78 | -------------------------------------------------------------------------------- /server/src/config.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | import { DEFAULT_LOG_LEVEL, LOG_LEVEL_ENV_VAR, LOG_LEVELS } from './util/logger' 4 | 5 | export const ConfigSchema = z.object({ 6 | // Maximum number of files to analyze in the background. Set to 0 to disable background analysis. 7 | backgroundAnalysisMaxFiles: z.number().int().min(0).default(500), 8 | 9 | // Enable diagnostics for source errors. Ignored if includeAllWorkspaceSymbols is true. 10 | enableSourceErrorDiagnostics: z.boolean().default(false), 11 | 12 | // Glob pattern for finding and parsing shell script files in the workspace. Used by the background analysis features across files. 13 | globPattern: z.string().trim().default('**/*@(.sh|.inc|.bash|.command)'), 14 | 15 | // Configure explainshell server endpoint in order to get hover documentation on flags and options. 16 | // And empty string will disable the feature. 17 | explainshellEndpoint: z.string().trim().default(''), 18 | 19 | // Log level for the server. To set the right log level from the start please also use the environment variable 'BASH_IDE_LOG_LEVEL'. 20 | logLevel: z.enum(LOG_LEVELS).default(DEFAULT_LOG_LEVEL), 21 | 22 | // Controls how symbols (e.g. variables and functions) are included and used for completion, documentation, and renaming. 23 | // If false, then we only include symbols from sourced files (i.e. using non dynamic statements like 'source file.sh' or '. file.sh' or following ShellCheck directives). 24 | // If true, then all symbols from the workspace are included. 25 | includeAllWorkspaceSymbols: z.boolean().default(false), 26 | 27 | // Additional ShellCheck arguments. Note that we already add the following arguments: --shell, --format, --external-sources." 28 | shellcheckArguments: z 29 | .preprocess((arg) => { 30 | let argsList: string[] = [] 31 | if (typeof arg === 'string') { 32 | argsList = arg.split(' ') 33 | } else if (Array.isArray(arg)) { 34 | argsList = arg as string[] 35 | } 36 | 37 | return argsList.map((s) => s.trim()).filter((s) => s.length > 0) 38 | }, z.array(z.string())) 39 | .default([]), 40 | 41 | // Controls the executable used for ShellCheck linting information. An empty string will disable linting. 42 | shellcheckPath: z.string().trim().default('shellcheck'), 43 | 44 | shfmt: z 45 | .object({ 46 | // Controls the executable used for Shfmt formatting. An empty string will disable formatting 47 | path: z.string().trim().default('shfmt'), 48 | 49 | // Ignore shfmt config options in .editorconfig (always use language server config) 50 | ignoreEditorconfig: z.boolean().default(false), 51 | 52 | // Language dialect to use when parsing (bash/posix/mksh/bats). 53 | languageDialect: z.enum(['auto', 'bash', 'posix', 'mksh', 'bats']).default('auto'), 54 | 55 | // Allow boolean operators (like && and ||) to start a line. 56 | binaryNextLine: z.boolean().default(false), 57 | 58 | // Indent patterns in case statements. 59 | caseIndent: z.boolean().default(false), 60 | 61 | // Place function opening braces on a separate line. 62 | funcNextLine: z.boolean().default(false), 63 | 64 | // (Deprecated) Keep column alignment padding. 65 | keepPadding: z.boolean().default(false), 66 | 67 | // Simplify code before formatting. 68 | simplifyCode: z.boolean().default(false), 69 | 70 | // Follow redirection operators with a space. 71 | spaceRedirects: z.boolean().default(false), 72 | }) 73 | .default({}), 74 | }) 75 | 76 | export type Config = z.infer 77 | 78 | export function getConfigFromEnvironmentVariables(): { 79 | config: Config 80 | environmentVariablesUsed: string[] 81 | } { 82 | const rawConfig = { 83 | backgroundAnalysisMaxFiles: toNumber(process.env.BACKGROUND_ANALYSIS_MAX_FILES), 84 | enableSourceErrorDiagnostics: toBoolean(process.env.ENABLE_SOURCE_ERROR_DIAGNOSTICS), 85 | explainshellEndpoint: process.env.EXPLAINSHELL_ENDPOINT, 86 | globPattern: process.env.GLOB_PATTERN, 87 | includeAllWorkspaceSymbols: toBoolean(process.env.INCLUDE_ALL_WORKSPACE_SYMBOLS), 88 | logLevel: process.env[LOG_LEVEL_ENV_VAR], 89 | shellcheckArguments: process.env.SHELLCHECK_ARGUMENTS, 90 | shellcheckPath: process.env.SHELLCHECK_PATH, 91 | shfmt: { 92 | path: process.env.SHFMT_PATH, 93 | ignoreEditorconfig: toBoolean(process.env.SHFMT_IGNORE_EDITORCONFIG), 94 | languageDialect: process.env.SHFMT_LANGUAGE_DIALECT, 95 | binaryNextLine: toBoolean(process.env.SHFMT_BINARY_NEXT_LINE), 96 | caseIndent: toBoolean(process.env.SHFMT_CASE_INDENT), 97 | funcNextLine: toBoolean(process.env.SHFMT_FUNC_NEXT_LINE), 98 | keepPadding: toBoolean(process.env.SHFMT_KEEP_PADDING), 99 | simplifyCode: toBoolean(process.env.SHFMT_SIMPLIFY_CODE), 100 | spaceRedirects: toBoolean(process.env.SHFMT_SPACE_REDIRECTS), 101 | }, 102 | } 103 | 104 | const environmentVariablesUsed = Object.entries(rawConfig) 105 | .filter( 106 | ([key, value]) => 107 | !['undefined', 'object'].includes(typeof value) && 108 | ![null, 'logLevel'].includes(key), 109 | ) 110 | .map(([key]) => key) 111 | 112 | const config = ConfigSchema.parse(rawConfig) 113 | 114 | return { config, environmentVariablesUsed } 115 | } 116 | 117 | export function getDefaultConfiguration(): Config { 118 | return ConfigSchema.parse({}) 119 | } 120 | 121 | const toBoolean = (s?: string): boolean | undefined => 122 | typeof s !== 'undefined' ? s === 'true' || s === '1' : undefined 123 | 124 | const toNumber = (s?: string): number | undefined => 125 | typeof s !== 'undefined' ? parseInt(s, 10) : undefined 126 | -------------------------------------------------------------------------------- /server/src/executables.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import { basename, join } from 'path' 3 | 4 | import * as ArrayUtil from './util/array' 5 | import * as FsUtil from './util/fs' 6 | 7 | /** 8 | * Provides information based on the programs on your PATH 9 | */ 10 | export default class Executables { 11 | private executables: Set 12 | 13 | private constructor(executables: string[]) { 14 | this.executables = new Set(executables) 15 | } 16 | 17 | /** 18 | * @param path is expected to to be a ':' separated list of paths. 19 | */ 20 | public static fromPath(path: string): Promise { 21 | const paths = path.split(':') 22 | const promises = paths.map((x) => findExecutablesInPath(x)) 23 | return Promise.all(promises) 24 | .then(ArrayUtil.flattenArray) 25 | .then(ArrayUtil.uniq) 26 | .then((executables) => new Executables(executables)) 27 | } 28 | 29 | /** 30 | * Find all programs in your PATH 31 | */ 32 | public list(): string[] { 33 | return Array.from(this.executables.values()) 34 | } 35 | 36 | /** 37 | * Check if the the given {{executable}} exists on the PATH 38 | */ 39 | public isExecutableOnPATH(executable: string): boolean { 40 | return this.executables.has(executable) 41 | } 42 | } 43 | 44 | /** 45 | * Only returns direct children, or the path itself if it's an executable. 46 | */ 47 | async function findExecutablesInPath(path: string): Promise { 48 | path = FsUtil.untildify(path) 49 | 50 | try { 51 | const pathStats = await fs.promises.lstat(path) 52 | 53 | if (pathStats.isDirectory()) { 54 | const childrenPaths = await fs.promises.readdir(path) 55 | 56 | const files = [] 57 | 58 | for (const childrenPath of childrenPaths) { 59 | try { 60 | const stats = await fs.promises.lstat(join(path, childrenPath)) 61 | if (isExecutableFile(stats)) { 62 | files.push(basename(childrenPath)) 63 | } 64 | } catch (error) { 65 | // Ignore error 66 | } 67 | } 68 | 69 | return files 70 | } else if (isExecutableFile(pathStats)) { 71 | return [basename(path)] 72 | } 73 | } catch (error) { 74 | // Ignore error 75 | } 76 | 77 | return [] 78 | } 79 | 80 | function isExecutableFile(stats: fs.Stats): boolean { 81 | const isExecutable = !!(1 & parseInt((stats.mode & parseInt('777', 8)).toString(8)[0])) 82 | return stats.isFile() && isExecutable 83 | } 84 | -------------------------------------------------------------------------------- /server/src/get-options.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Try and get COMPLETIONSRC using pkg-config 4 | COMPLETIONSDIR="$(pkg-config --variable=completionsdir bash-completion)" 5 | 6 | if (( $? == 0 )) 7 | then 8 | COMPLETIONSRC="$(dirname "$COMPLETIONSDIR")/bash_completion" 9 | else 10 | # Fallback if pkg-config fails 11 | if [ "$(uname -s)" = "Darwin" ] 12 | then 13 | # Running macOS 14 | COMPLETIONSRC="$(brew --prefix)/etc/bash_completion" 15 | else 16 | # Suppose running Linux 17 | COMPLETIONSRC="${PREFIX:-/usr}/share/bash-completion/bash_completion" 18 | fi 19 | fi 20 | 21 | # Validate path of COMPLETIONSRC 22 | if (( $? != 0 )) || [ ! -r "$COMPLETIONSRC" ] 23 | then 24 | exit 1 25 | fi 26 | 27 | source "$COMPLETIONSRC" 28 | 29 | COMP_LINE="$*" 30 | COMP_WORDS=("$@") 31 | COMP_CWORD="${#COMP_WORDS[@]}" 32 | ((COMP_CWORD--)) 33 | COMP_POINT="${#COMP_LINE}" 34 | COMP_WORDBREAKS='"'"'><=;|&(:" 35 | 36 | _command_offset 0 2> /dev/null 37 | 38 | if (( ${#COMPREPLY[@]} == 0 )) 39 | then 40 | # Disabled by default because _longopt executes the program 41 | # to get its options. 42 | if (( ${BASH_LSP_COMPLETE_LONGOPTS} == 1 )) 43 | then 44 | _longopt "${COMP_WORDS[0]}" 45 | fi 46 | fi 47 | 48 | printf "%s\t" "${COMPREPLY[@]}" 49 | -------------------------------------------------------------------------------- /server/src/parser.ts: -------------------------------------------------------------------------------- 1 | import * as Parser from 'web-tree-sitter' 2 | 3 | const _global: any = global 4 | 5 | export async function initializeParser(): Promise { 6 | if (_global.fetch) { 7 | // NOTE: temporary workaround for emscripten node 18 support. 8 | // emscripten is used for compiling tree-sitter to wasm. 9 | // https://github.com/emscripten-core/emscripten/issues/16915 10 | delete _global.fetch 11 | } 12 | 13 | await Parser.init() 14 | const parser = new Parser() 15 | 16 | /** 17 | * See https://github.com/tree-sitter/tree-sitter/tree/master/lib/binding_web#generate-wasm-language-files 18 | * 19 | * To compile and use a new tree-sitter-bash version: 20 | * sh scripts/upgrade-tree-sitter.sh 21 | */ 22 | const lang = await Parser.Language.load(`${__dirname}/../tree-sitter-bash.wasm`) 23 | 24 | parser.setLanguage(lang) 25 | return parser 26 | } 27 | -------------------------------------------------------------------------------- /server/src/reserved-words.ts: -------------------------------------------------------------------------------- 1 | // https://www.gnu.org/software/bash/manual/html_node/Reserved-Word-Index.html 2 | 3 | export const LIST = [ 4 | '!', 5 | '[[', 6 | ']]', 7 | '{', 8 | '}', 9 | 'case', 10 | 'do', 11 | 'done', 12 | 'elif', 13 | 'else', 14 | 'esac', 15 | 'fi', 16 | 'for', 17 | 'function', 18 | 'if', 19 | 'in', 20 | 'select', 21 | 'then', 22 | 'time', 23 | 'until', 24 | 'while', 25 | ] 26 | 27 | const SET = new Set(LIST) 28 | 29 | export function isReservedWord(word: string): boolean { 30 | return SET.has(word) 31 | } 32 | -------------------------------------------------------------------------------- /server/src/shellcheck/__tests__/config.test.ts: -------------------------------------------------------------------------------- 1 | import { ShellCheckResultSchema } from '../types' 2 | 3 | describe('shellcheck', () => { 4 | it('asserts one valid shellcheck JSON comment', async () => { 5 | // prettier-ignore 6 | const shellcheckJSON = { 7 | comments: [ 8 | { file: 'testing/fixtures/comment-doc-on-hover.sh', line: 43, endLine: 43, column: 1, endColumn: 7, level: 'warning', code: 2034, message: 'bork bork', fix: null, }, 9 | ], 10 | } 11 | ShellCheckResultSchema.parse(shellcheckJSON) 12 | }) 13 | 14 | it('asserts two valid shellcheck JSON comment', async () => { 15 | // prettier-ignore 16 | const shellcheckJSON = { 17 | comments: [ 18 | { file: 'testing/fixtures/comment-doc-on-hover.sh', line: 43, endLine: 43, column: 1, endColumn: 7, level: 'warning', code: 2034, message: 'bork bork', fix: null, }, 19 | { file: 'testing/fixtures/comment-doc-on-hover.sh', line: 45, endLine: 45, column: 2, endColumn: 8, level: 'warning', code: 2035, message: 'bork bork', fix: null, }, 20 | ], 21 | } 22 | ShellCheckResultSchema.parse(shellcheckJSON) 23 | }) 24 | 25 | it('fails shellcheck JSON with null comments', async () => { 26 | const shellcheckJSON = { comments: null } 27 | expect(() => ShellCheckResultSchema.parse(shellcheckJSON)).toThrow() 28 | }) 29 | 30 | it('fails shellcheck JSON with string commment', async () => { 31 | const shellcheckJSON = { comments: ['foo'] } 32 | expect(() => ShellCheckResultSchema.parse(shellcheckJSON)).toThrow() 33 | }) 34 | 35 | it('fails shellcheck JSON with invalid comment', async () => { 36 | const make = (tweaks = {}) => ({ 37 | comments: [ 38 | { 39 | file: 'testing/fixtures/comment-doc-on-hover.sh', 40 | line: 43, 41 | endLine: 43, 42 | column: 1, 43 | endColumn: 7, 44 | level: 'warning', 45 | code: 2034, 46 | message: 'bork bork', 47 | fix: null, 48 | ...tweaks, 49 | }, 50 | ], 51 | }) 52 | ShellCheckResultSchema.parse(make()) // Defaults should work 53 | 54 | expect(() => ShellCheckResultSchema.parse(make({ file: 9 }))).toThrow() 55 | expect(() => ShellCheckResultSchema.parse(make({ line: '9' }))).toThrow() 56 | expect(() => ShellCheckResultSchema.parse(make({ endLine: '9' }))).toThrow() 57 | expect(() => ShellCheckResultSchema.parse(make({ column: '9' }))).toThrow() 58 | expect(() => ShellCheckResultSchema.parse(make({ endColumn: '9' }))).toThrow() 59 | expect(() => ShellCheckResultSchema.parse(make({ level: 9 }))).toThrow() 60 | expect(() => ShellCheckResultSchema.parse(make({ code: '9' }))).toThrow() 61 | expect(() => ShellCheckResultSchema.parse(make({ message: 9 }))).toThrow() 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /server/src/shellcheck/__tests__/directive.test.ts: -------------------------------------------------------------------------------- 1 | import { parseShellCheckDirective } from '../directive' 2 | 3 | describe('parseShellCheckDirective', () => { 4 | it('parses a disable directive', () => { 5 | expect(parseShellCheckDirective('# shellcheck disable=SC1000')).toEqual([ 6 | { 7 | type: 'disable', 8 | rules: ['SC1000'], 9 | }, 10 | ]) 11 | }) 12 | 13 | it('parses a disable directive with multiple args', () => { 14 | expect(parseShellCheckDirective('# shellcheck disable=SC1000,SC1001')).toEqual([ 15 | { 16 | type: 'disable', 17 | rules: ['SC1000', 'SC1001'], 18 | }, 19 | ]) 20 | 21 | expect( 22 | parseShellCheckDirective( 23 | '# shellcheck disable=SC1000,SC2000-SC2002,SC1001 # this is a comment', 24 | ), 25 | ).toEqual([ 26 | { 27 | type: 'disable', 28 | rules: ['SC1000', 'SC2000', 'SC2001', 'SC2002', 'SC1001'], 29 | }, 30 | ]) 31 | 32 | expect(parseShellCheckDirective('# shellcheck disable=SC1000,SC1001')).toEqual([ 33 | { 34 | type: 'disable', 35 | rules: ['SC1000', 'SC1001'], 36 | }, 37 | ]) 38 | 39 | expect(parseShellCheckDirective('# shellcheck disable=SC1000,SC1001')).toEqual([ 40 | { 41 | type: 'disable', 42 | rules: ['SC1000', 'SC1001'], 43 | }, 44 | ]) 45 | }) 46 | 47 | // SC1000-SC9999 48 | it('parses a disable directive with a range', () => { 49 | expect(parseShellCheckDirective('# shellcheck disable=SC1000-SC1005')).toEqual([ 50 | { 51 | type: 'disable', 52 | rules: ['SC1000', 'SC1001', 'SC1002', 'SC1003', 'SC1004', 'SC1005'], 53 | }, 54 | ]) 55 | }) 56 | 57 | it('parses a disable directive with all', () => { 58 | expect(parseShellCheckDirective('# shellcheck disable=all')).toEqual([ 59 | { 60 | type: 'disable', 61 | rules: ['all'], 62 | }, 63 | ]) 64 | }) 65 | 66 | it('parses an enable directive', () => { 67 | expect( 68 | parseShellCheckDirective('# shellcheck enable=require-variable-braces'), 69 | ).toEqual([ 70 | { 71 | type: 'enable', 72 | rules: ['require-variable-braces'], 73 | }, 74 | ]) 75 | }) 76 | 77 | it('parses source directive', () => { 78 | expect(parseShellCheckDirective('# shellcheck source=foo.sh')).toEqual([ 79 | { 80 | type: 'source', 81 | path: 'foo.sh', 82 | }, 83 | ]) 84 | 85 | expect(parseShellCheckDirective('# shellcheck source=/dev/null # a comment')).toEqual( 86 | [ 87 | { 88 | type: 'source', 89 | path: '/dev/null', 90 | }, 91 | ], 92 | ) 93 | }) 94 | 95 | it('parses source-path directive', () => { 96 | expect(parseShellCheckDirective('# shellcheck source-path=src/examples')).toEqual([ 97 | { 98 | type: 'source-path', 99 | path: 'src/examples', 100 | }, 101 | ]) 102 | 103 | expect(parseShellCheckDirective('# shellcheck source-path=SCRIPTDIR')).toEqual([ 104 | { 105 | type: 'source-path', 106 | path: 'SCRIPTDIR', 107 | }, 108 | ]) 109 | }) 110 | 111 | it('supports multiple directives on the same line', () => { 112 | expect( 113 | parseShellCheckDirective( 114 | `# shellcheck cats=dogs disable=SC1234,SC2345 enable="foo" shell=bash`, 115 | ), 116 | ).toEqual([ 117 | { 118 | type: 'disable', 119 | rules: ['SC1234', 'SC2345'], 120 | }, 121 | { 122 | type: 'enable', 123 | rules: ['"foo"'], 124 | }, 125 | { 126 | type: 'shell', 127 | shell: 'bash', 128 | }, 129 | ]) 130 | }) 131 | 132 | it('parses a line with no directive', () => { 133 | expect(parseShellCheckDirective('# foo bar')).toEqual([]) 134 | }) 135 | 136 | it('does not throw on invalid directives', () => { 137 | expect(parseShellCheckDirective('# shellcheck')).toEqual([]) 138 | expect(parseShellCheckDirective('# shellcheck disable = ')).toEqual([]) 139 | expect(parseShellCheckDirective('# shellcheck disable=SC2-SC1')).toEqual([ 140 | { type: 'disable', rules: [] }, 141 | ]) 142 | expect(parseShellCheckDirective('# shellcheck disable=SC0-SC-1')).toEqual([ 143 | { type: 'disable', rules: ['SC0-SC-1'] }, 144 | ]) 145 | }) 146 | }) 147 | -------------------------------------------------------------------------------- /server/src/shellcheck/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import { TextDocument } from 'vscode-languageserver-textdocument' 3 | 4 | import { FIXTURE_DOCUMENT, FIXTURE_FOLDER } from '../../../../testing/fixtures' 5 | import { Logger } from '../../util/logger' 6 | import { Linter } from '../index' 7 | 8 | jest.spyOn(Logger.prototype, 'log').mockImplementation(() => { 9 | // noop 10 | }) 11 | const loggerWarn = jest.spyOn(Logger.prototype, 'warn') 12 | 13 | jest.useFakeTimers() 14 | 15 | const FIXTURE_DOCUMENT_URI = `file://${FIXTURE_FOLDER}/foo.sh` 16 | function textToDoc(txt: string) { 17 | return TextDocument.create(FIXTURE_DOCUMENT_URI, 'bar', 0, txt) 18 | } 19 | 20 | async function getLintingResult({ 21 | additionalShellCheckArguments = [], 22 | cwd, 23 | document, 24 | executablePath = 'shellcheck', 25 | sourcePaths = [], 26 | }: { 27 | additionalShellCheckArguments?: string[] 28 | cwd?: string 29 | document: TextDocument 30 | executablePath?: string 31 | sourcePaths?: string[] 32 | }): Promise<[Awaited>, Linter]> { 33 | const linter = new Linter({ 34 | cwd, 35 | executablePath, 36 | }) 37 | const promise = linter.lint(document, sourcePaths, additionalShellCheckArguments) 38 | jest.runAllTimers() 39 | const result = await promise 40 | return [result, linter] 41 | } 42 | 43 | describe('linter', () => { 44 | it('default to canLint to true', () => { 45 | expect(new Linter({ executablePath: 'foo' }).canLint).toBe(true) 46 | }) 47 | 48 | it('should set canLint to false when linting fails', async () => { 49 | const executablePath = '77b4d3f6-c87a-11ec-9b62-a3c90f66d29f' 50 | 51 | const [result, linter] = await getLintingResult({ 52 | document: textToDoc(''), 53 | executablePath, 54 | }) 55 | 56 | expect(result).toEqual({ 57 | codeActions: {}, 58 | diagnostics: [], 59 | }) 60 | 61 | expect(linter.canLint).toBe(false) 62 | expect(loggerWarn).toBeCalledWith( 63 | expect.stringContaining( 64 | 'ShellCheck: disabling linting as no executable was found at path', 65 | ), 66 | ) 67 | }) 68 | 69 | it('should lint when shellcheck is present', async () => { 70 | // prettier-ignore 71 | const shell = [ 72 | '#!/bin/bash', 73 | 'echo $foo', 74 | ].join('\n') 75 | 76 | const [result] = await getLintingResult({ document: textToDoc(shell) }) 77 | expect(result).toMatchInlineSnapshot(` 78 | { 79 | "codeActions": { 80 | "shellcheck|2086|1:5-1:9": { 81 | "diagnostics": [ 82 | { 83 | "code": "SC2086", 84 | "codeDescription": { 85 | "href": "https://www.shellcheck.net/wiki/SC2086", 86 | }, 87 | "data": { 88 | "id": "shellcheck|2086|1:5-1:9", 89 | }, 90 | "message": "Double quote to prevent globbing and word splitting.", 91 | "range": { 92 | "end": { 93 | "character": 9, 94 | "line": 1, 95 | }, 96 | "start": { 97 | "character": 5, 98 | "line": 1, 99 | }, 100 | }, 101 | "severity": 3, 102 | "source": "shellcheck", 103 | "tags": undefined, 104 | }, 105 | ], 106 | "edit": { 107 | "changes": { 108 | "${FIXTURE_DOCUMENT_URI}": [ 109 | { 110 | "newText": """, 111 | "range": { 112 | "end": { 113 | "character": 9, 114 | "line": 1, 115 | }, 116 | "start": { 117 | "character": 9, 118 | "line": 1, 119 | }, 120 | }, 121 | }, 122 | { 123 | "newText": """, 124 | "range": { 125 | "end": { 126 | "character": 5, 127 | "line": 1, 128 | }, 129 | "start": { 130 | "character": 5, 131 | "line": 1, 132 | }, 133 | }, 134 | }, 135 | ], 136 | }, 137 | }, 138 | "kind": "quickfix", 139 | "title": "Apply fix for SC2086", 140 | }, 141 | }, 142 | "diagnostics": [ 143 | { 144 | "code": "SC2154", 145 | "codeDescription": { 146 | "href": "https://www.shellcheck.net/wiki/SC2154", 147 | }, 148 | "data": { 149 | "id": "shellcheck|2154|1:5-1:9", 150 | }, 151 | "message": "foo is referenced but not assigned.", 152 | "range": { 153 | "end": { 154 | "character": 9, 155 | "line": 1, 156 | }, 157 | "start": { 158 | "character": 5, 159 | "line": 1, 160 | }, 161 | }, 162 | "severity": 2, 163 | "source": "shellcheck", 164 | "tags": undefined, 165 | }, 166 | { 167 | "code": "SC2086", 168 | "codeDescription": { 169 | "href": "https://www.shellcheck.net/wiki/SC2086", 170 | }, 171 | "data": { 172 | "id": "shellcheck|2086|1:5-1:9", 173 | }, 174 | "message": "Double quote to prevent globbing and word splitting.", 175 | "range": { 176 | "end": { 177 | "character": 9, 178 | "line": 1, 179 | }, 180 | "start": { 181 | "character": 5, 182 | "line": 1, 183 | }, 184 | }, 185 | "severity": 3, 186 | "source": "shellcheck", 187 | "tags": undefined, 188 | }, 189 | ], 190 | } 191 | `) 192 | }) 193 | 194 | it('should debounce the lint requests', async () => { 195 | const linter = new Linter({ 196 | cwd: FIXTURE_FOLDER, 197 | executablePath: 'shellcheck', 198 | }) 199 | 200 | const lintCalls = 100 201 | const promises = [...Array(lintCalls)].map(() => 202 | linter.lint(FIXTURE_DOCUMENT.SHELLCHECK_SOURCE, []), 203 | ) 204 | 205 | jest.runOnlyPendingTimers() 206 | 207 | const result = await promises[promises.length - 1] 208 | expect(result).toEqual({ 209 | codeActions: {}, 210 | diagnostics: [], 211 | }) 212 | }) 213 | 214 | it('should correctly follow sources with correct cwd', async () => { 215 | const [result] = await getLintingResult({ 216 | cwd: FIXTURE_FOLDER, 217 | document: FIXTURE_DOCUMENT.SHELLCHECK_SOURCE, 218 | }) 219 | 220 | expect(result).toEqual({ 221 | codeActions: {}, 222 | diagnostics: [], 223 | }) 224 | }) 225 | 226 | it('should fail to follow sources with incorrect cwd', async () => { 227 | const [result] = await getLintingResult({ 228 | cwd: path.resolve(path.join(FIXTURE_FOLDER, '../')), 229 | document: FIXTURE_DOCUMENT.SHELLCHECK_SOURCE, 230 | }) 231 | 232 | expect(result).toMatchInlineSnapshot(` 233 | { 234 | "codeActions": {}, 235 | "diagnostics": [ 236 | { 237 | "code": "SC1091", 238 | "codeDescription": { 239 | "href": "https://www.shellcheck.net/wiki/SC1091", 240 | }, 241 | "data": { 242 | "id": "shellcheck|1091|3:7-3:19", 243 | }, 244 | "message": "Not following: shellcheck/sourced.sh: openBinaryFile: does not exist (No such file or directory)", 245 | "range": { 246 | "end": { 247 | "character": 19, 248 | "line": 3, 249 | }, 250 | "start": { 251 | "character": 7, 252 | "line": 3, 253 | }, 254 | }, 255 | "severity": 3, 256 | "source": "shellcheck", 257 | "tags": undefined, 258 | }, 259 | { 260 | "code": "SC2154", 261 | "codeDescription": { 262 | "href": "https://www.shellcheck.net/wiki/SC2154", 263 | }, 264 | "data": { 265 | "id": "shellcheck|2154|5:6-5:10", 266 | }, 267 | "message": "foo is referenced but not assigned.", 268 | "range": { 269 | "end": { 270 | "character": 10, 271 | "line": 5, 272 | }, 273 | "start": { 274 | "character": 6, 275 | "line": 5, 276 | }, 277 | }, 278 | "severity": 2, 279 | "source": "shellcheck", 280 | "tags": undefined, 281 | }, 282 | ], 283 | } 284 | `) 285 | }) 286 | 287 | it('should follow sources with incorrect cwd if the execution path is passed', async () => { 288 | const [result] = await getLintingResult({ 289 | cwd: path.resolve(path.join(FIXTURE_FOLDER, '../')), 290 | document: FIXTURE_DOCUMENT.SHELLCHECK_SOURCE, 291 | sourcePaths: [path.resolve(FIXTURE_FOLDER)], 292 | }) 293 | expect(result).toEqual({ 294 | codeActions: {}, 295 | diagnostics: [], 296 | }) 297 | }) 298 | }) 299 | -------------------------------------------------------------------------------- /server/src/shellcheck/config.ts: -------------------------------------------------------------------------------- 1 | import * as LSP from 'vscode-languageserver/node' 2 | 3 | import { ShellCheckCommentLevel } from './types' 4 | 5 | export const SUPPORTED_BASH_DIALECTS = ['sh', 'bash', 'dash', 'ksh'] 6 | 7 | // https://github.com/koalaman/shellcheck/wiki 8 | export const CODE_TO_TAGS: Record = { 9 | 2034: [LSP.DiagnosticTag.Unnecessary], 10 | } 11 | 12 | // https://github.com/koalaman/shellcheck/blob/364c33395e2f2d5500307f01989f70241c247d5a/src/ShellCheck/Formatter/Format.hs#L50 13 | 14 | export const LEVEL_TO_SEVERITY: Record< 15 | ShellCheckCommentLevel, 16 | LSP.DiagnosticSeverity | undefined 17 | > = { 18 | error: LSP.DiagnosticSeverity.Error, 19 | warning: LSP.DiagnosticSeverity.Warning, 20 | info: LSP.DiagnosticSeverity.Information, 21 | style: LSP.DiagnosticSeverity.Hint, 22 | } 23 | -------------------------------------------------------------------------------- /server/src/shellcheck/directive.ts: -------------------------------------------------------------------------------- 1 | const DIRECTIVE_TYPES = ['enable', 'disable', 'source', 'source-path', 'shell'] as const 2 | type DirectiveType = (typeof DIRECTIVE_TYPES)[number] 3 | 4 | type Directive = 5 | | { 6 | type: 'enable' 7 | rules: string[] 8 | } 9 | | { 10 | type: 'disable' 11 | rules: string[] 12 | } 13 | | { 14 | type: 'source' 15 | path: string 16 | } 17 | | { 18 | type: 'source-path' 19 | path: string 20 | } 21 | | { 22 | type: 'shell' 23 | shell: string 24 | } 25 | 26 | const DIRECTIVE_REG_EXP = /^(#\s*shellcheck\s+)([^#]*)/ 27 | 28 | export function parseShellCheckDirective(line: string): Directive[] { 29 | const match = line.match(DIRECTIVE_REG_EXP) 30 | 31 | if (!match) { 32 | return [] 33 | } 34 | 35 | const commands = match[2] 36 | .split(' ') 37 | .map((command) => command.trim()) 38 | .filter((command) => command !== '') 39 | 40 | const directives: Directive[] = [] 41 | 42 | for (const command of commands) { 43 | const [typeKey, directiveValue] = command.split('=') 44 | const type = DIRECTIVE_TYPES.includes(typeKey as any) 45 | ? (typeKey as DirectiveType) 46 | : null 47 | 48 | if (!type || !directiveValue) { 49 | continue 50 | } 51 | 52 | if (type === 'source-path' || type === 'source') { 53 | directives.push({ 54 | type, 55 | path: directiveValue, 56 | }) 57 | } else if (type === 'shell') { 58 | directives.push({ 59 | type, 60 | shell: directiveValue, 61 | }) 62 | continue 63 | } else if (type === 'enable' || type === 'disable') { 64 | const rules = [] 65 | 66 | for (const arg of directiveValue.split(',')) { 67 | const ruleRangeMatch = arg.match(/^SC(\d*)-SC(\d*)$/) 68 | if (ruleRangeMatch) { 69 | for ( 70 | let i = parseInt(ruleRangeMatch[1], 10); 71 | i <= parseInt(ruleRangeMatch[2], 10); 72 | i++ 73 | ) { 74 | rules.push(`SC${i}`) 75 | } 76 | } else { 77 | arg 78 | .split(',') 79 | .map((arg) => arg.trim()) 80 | .filter((arg) => arg !== '') 81 | .forEach((arg) => rules.push(arg)) 82 | } 83 | } 84 | 85 | directives.push({ 86 | type, 87 | rules, 88 | }) 89 | } 90 | } 91 | 92 | return directives 93 | } 94 | -------------------------------------------------------------------------------- /server/src/shellcheck/index.ts: -------------------------------------------------------------------------------- 1 | import { dirname } from 'node:path' 2 | import { fileURLToPath } from 'node:url' 3 | 4 | import { spawn } from 'child_process' 5 | import * as LSP from 'vscode-languageserver/node' 6 | import { TextDocument } from 'vscode-languageserver-textdocument' 7 | 8 | import { debounce } from '../util/async' 9 | import { logger } from '../util/logger' 10 | import { analyzeShebang } from '../util/shebang' 11 | import { CODE_TO_TAGS, LEVEL_TO_SEVERITY } from './config' 12 | import { 13 | ShellCheckComment, 14 | ShellCheckReplacement, 15 | ShellCheckResult, 16 | ShellCheckResultSchema, 17 | } from './types' 18 | 19 | const SUPPORTED_BASH_DIALECTS = ['sh', 'bash', 'dash', 'ksh'] 20 | const DEBOUNCE_MS = 500 21 | type LinterOptions = { 22 | executablePath: string 23 | cwd?: string 24 | } 25 | 26 | export type LintingResult = { 27 | diagnostics: LSP.Diagnostic[] 28 | codeActions: Record 29 | } 30 | 31 | export class Linter { 32 | private cwd: string 33 | public executablePath: string 34 | private uriToDebouncedExecuteLint: { 35 | [uri: string]: InstanceType['executeLint'] 36 | } 37 | private _canLint: boolean 38 | 39 | constructor({ cwd, executablePath }: LinterOptions) { 40 | this._canLint = true 41 | this.cwd = cwd || process.cwd() 42 | this.executablePath = executablePath 43 | this.uriToDebouncedExecuteLint = Object.create(null) 44 | } 45 | 46 | public get canLint(): boolean { 47 | return this._canLint 48 | } 49 | 50 | public async lint( 51 | document: TextDocument, 52 | sourcePaths: string[], 53 | additionalShellCheckArguments: string[] = [], 54 | ): Promise { 55 | if (!this._canLint) { 56 | return { diagnostics: [], codeActions: {} } 57 | } 58 | 59 | const { uri } = document 60 | let debouncedExecuteLint = this.uriToDebouncedExecuteLint[uri] 61 | if (!debouncedExecuteLint) { 62 | debouncedExecuteLint = debounce(this.executeLint.bind(this), DEBOUNCE_MS) 63 | this.uriToDebouncedExecuteLint[uri] = debouncedExecuteLint 64 | } 65 | 66 | return debouncedExecuteLint(document, sourcePaths, additionalShellCheckArguments) 67 | } 68 | 69 | private async executeLint( 70 | document: TextDocument, 71 | sourcePaths: string[], 72 | additionalShellCheckArguments: string[] = [], 73 | ): Promise { 74 | const documentText = document.getText() 75 | 76 | const shellDialect = guessShellDialect({ 77 | documentText, 78 | uri: document.uri, 79 | }) 80 | 81 | if (shellDialect && !SUPPORTED_BASH_DIALECTS.includes(shellDialect)) { 82 | // We found a dialect that isn't supported by ShellCheck. 83 | return { diagnostics: [], codeActions: {} } 84 | } 85 | 86 | // NOTE: that ShellCheck actually does shebang parsing, but we manually 87 | // do it here in order to fallback to bash for files without a shebang. 88 | // This enables parsing files with a bash syntax, but could yield false positives. 89 | const shellName = 90 | shellDialect && SUPPORTED_BASH_DIALECTS.includes(shellDialect) 91 | ? shellDialect 92 | : 'bash' 93 | 94 | const result = await this.runShellCheck( 95 | documentText, 96 | shellName, 97 | [...sourcePaths, dirname(fileURLToPath(document.uri))], 98 | additionalShellCheckArguments, 99 | ) 100 | 101 | if (!this._canLint) { 102 | return { diagnostics: [], codeActions: {} } 103 | } 104 | 105 | // Clean up the debounced function 106 | delete this.uriToDebouncedExecuteLint[document.uri] 107 | 108 | return mapShellCheckResult({ uri: document.uri, result }) 109 | } 110 | 111 | private async runShellCheck( 112 | documentText: string, 113 | shellName: string, 114 | sourcePaths: string[], 115 | additionalArgs: string[] = [], 116 | ): Promise { 117 | const sourcePathsArgs = sourcePaths 118 | .map((folder) => folder.trim()) 119 | .filter((folderName) => folderName) 120 | .map((folderName) => `--source-path=${folderName}`) 121 | 122 | const args = [ 123 | '--format=json1', 124 | '--external-sources', 125 | ...sourcePathsArgs, 126 | ...additionalArgs, 127 | ] 128 | 129 | // only add `--shell` argument if non is provided by the user in their 130 | // config. This allows to the user to override the shell. See #1064. 131 | const userArgs = additionalArgs.join(' ') 132 | if (!(userArgs.includes('--shell') || userArgs.includes('-s '))) { 133 | args.unshift(`--shell=${shellName}`) 134 | } 135 | 136 | logger.debug(`ShellCheck: running "${this.executablePath} ${args.join(' ')}"`) 137 | 138 | let out = '' 139 | let err = '' 140 | const proc = new Promise((resolve, reject) => { 141 | const proc = spawn(this.executablePath, [...args, '-'], { cwd: this.cwd }) 142 | proc.on('error', reject) 143 | proc.on('close', resolve) 144 | proc.stdout.on('data', (data) => (out += data)) 145 | proc.stderr.on('data', (data) => (err += data)) 146 | proc.stdin.on('error', () => { 147 | // NOTE: Ignore STDIN errors in case the process ends too quickly, before we try to 148 | // write. If we write after the process ends without this, we get an uncatchable EPIPE. 149 | // This is solved in Node >= 15.1 by the "on('spawn', ...)" event, but we need to 150 | // support earlier versions. 151 | }) 152 | proc.stdin.end(documentText) 153 | }) 154 | 155 | // NOTE: do we care about exit code? 0 means "ok", 1 possibly means "errors", 156 | // but the presence of parseable errors in the output is also sufficient to 157 | // distinguish. 158 | let exit 159 | try { 160 | exit = await proc 161 | } catch (e) { 162 | // TODO: we could do this up front? 163 | if ((e as any).code === 'ENOENT') { 164 | // shellcheck path wasn't found, don't try to lint any more: 165 | logger.warn( 166 | `ShellCheck: disabling linting as no executable was found at path '${this.executablePath}'`, 167 | ) 168 | this._canLint = false 169 | return { comments: [] } 170 | } 171 | throw new Error( 172 | `ShellCheck: failed with code ${exit}: ${e}\nout:\n${out}\nerr:\n${err}`, 173 | ) 174 | } 175 | 176 | let raw 177 | try { 178 | raw = JSON.parse(out) 179 | } catch (e) { 180 | throw new Error( 181 | `ShellCheck: json parse failed with error ${e}\nout:\n${out}\nerr:\n${err}`, 182 | ) 183 | } 184 | 185 | return ShellCheckResultSchema.parse(raw) 186 | } 187 | } 188 | 189 | function mapShellCheckResult({ uri, result }: { uri: string; result: ShellCheckResult }) { 190 | const diagnostics: LintingResult['diagnostics'] = [] 191 | const codeActions: LintingResult['codeActions'] = {} 192 | 193 | for (const comment of result.comments) { 194 | const range = LSP.Range.create( 195 | { 196 | line: comment.line - 1, 197 | character: comment.column - 1, 198 | }, 199 | { 200 | line: comment.endLine - 1, 201 | character: comment.endColumn - 1, 202 | }, 203 | ) 204 | 205 | const id = `shellcheck|${comment.code}|${range.start.line}:${range.start.character}-${range.end.line}:${range.end.character}` 206 | 207 | const diagnostic: LSP.Diagnostic = { 208 | message: comment.message, 209 | severity: LEVEL_TO_SEVERITY[comment.level] || LSP.DiagnosticSeverity.Error, 210 | code: `SC${comment.code}`, 211 | source: 'shellcheck', 212 | range, 213 | codeDescription: { 214 | href: `https://www.shellcheck.net/wiki/SC${comment.code}`, 215 | }, 216 | tags: CODE_TO_TAGS[comment.code], 217 | data: { 218 | id, 219 | }, 220 | } 221 | 222 | diagnostics.push(diagnostic) 223 | 224 | const codeAction = CodeActionProvider.getCodeAction({ 225 | comment, 226 | diagnostics: [diagnostic], 227 | uri, 228 | }) 229 | 230 | if (codeAction) { 231 | codeActions[id] = codeAction 232 | } 233 | } 234 | 235 | return { diagnostics, codeActions } 236 | } 237 | 238 | /** 239 | * Code has been adopted from https://github.com/vscode-shellcheck/vscode-shellcheck/ 240 | * and modified to fit the needs of this project. 241 | * 242 | * The MIT License (MIT) 243 | * Copyright (c) Timon Wong 244 | */ 245 | class CodeActionProvider { 246 | public static getCodeAction({ 247 | comment, 248 | diagnostics, 249 | uri, 250 | }: { 251 | comment: ShellCheckComment 252 | diagnostics: LSP.Diagnostic[] 253 | uri: string 254 | }): LSP.CodeAction | null { 255 | const { code, fix } = comment 256 | if (!fix || fix.replacements.length === 0) { 257 | return null 258 | } 259 | 260 | const { replacements } = fix 261 | if (replacements.length === 0) { 262 | return null 263 | } 264 | 265 | const edits = this.getTextEdits(replacements) 266 | if (!edits.length) { 267 | return null 268 | } 269 | 270 | return { 271 | title: `Apply fix for SC${code}`, 272 | diagnostics, 273 | edit: { 274 | changes: { 275 | [uri]: edits, 276 | }, 277 | }, 278 | kind: LSP.CodeActionKind.QuickFix, 279 | } 280 | } 281 | private static getTextEdits( 282 | replacements: ReadonlyArray, 283 | ): LSP.TextEdit[] { 284 | if (replacements.length === 1) { 285 | return [this.getTextEdit(replacements[0])] 286 | } else if (replacements.length === 2) { 287 | return [this.getTextEdit(replacements[1]), this.getTextEdit(replacements[0])] 288 | } 289 | 290 | return [] 291 | } 292 | private static getTextEdit(replacement: ShellCheckReplacement): LSP.TextEdit { 293 | const startPos = LSP.Position.create(replacement.line - 1, replacement.column - 1) 294 | const endPos = LSP.Position.create(replacement.endLine - 1, replacement.endColumn - 1) 295 | return { 296 | range: LSP.Range.create(startPos, endPos), 297 | newText: replacement.replacement, 298 | } 299 | } 300 | } 301 | 302 | function guessShellDialect({ documentText, uri }: { documentText: string; uri: string }) { 303 | return uri.endsWith('.zsh') ? 'zsh' : analyzeShebang(documentText).shellDialect 304 | } 305 | -------------------------------------------------------------------------------- /server/src/shellcheck/types.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | const ReplacementSchema = z.object({ 4 | precedence: z.number(), 5 | line: z.number(), 6 | endLine: z.number(), 7 | column: z.number(), 8 | endColumn: z.number(), 9 | insertionPoint: z.string(), 10 | replacement: z.string(), 11 | }) 12 | 13 | // https://github.com/koalaman/shellcheck/blob/364c33395e2f2d5500307f01989f70241c247d5a/src/ShellCheck/Formatter/Format.hs#L50 14 | const LevelSchema = z.enum(['error', 'warning', 'info', 'style']) 15 | 16 | // Constituent structures defined here: 17 | // https://github.com/koalaman/shellcheck/blob/master/src/ShellCheck/Interface.hs 18 | 19 | export const ShellCheckResultSchema = z.object({ 20 | comments: z.array( 21 | z.object({ 22 | file: z.string(), 23 | line: z.number(), // 1-based 24 | endLine: z.number(), // 1-based 25 | column: z.number(), // 1-based 26 | endColumn: z.number(), // 1-based 27 | level: LevelSchema, 28 | code: z.number(), 29 | message: z.string(), 30 | fix: z 31 | .object({ 32 | replacements: z.array(ReplacementSchema), 33 | }) 34 | .nullable(), 35 | }), 36 | ), 37 | }) 38 | export type ShellCheckResult = z.infer 39 | export type ShellCheckComment = ShellCheckResult['comments'][number] 40 | export type ShellCheckCommentLevel = z.infer 41 | export type ShellCheckReplacement = z.infer 42 | -------------------------------------------------------------------------------- /server/src/shfmt/index.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process' 2 | import * as editorconfig from 'editorconfig' 3 | import * as LSP from 'vscode-languageserver/node' 4 | import { DocumentUri, TextDocument, TextEdit } from 'vscode-languageserver-textdocument' 5 | 6 | import { logger } from '../util/logger' 7 | 8 | type FormatterOptions = { 9 | executablePath: string 10 | cwd?: string 11 | } 12 | 13 | export class Formatter { 14 | private cwd: string 15 | public executablePath: string 16 | private _canFormat: boolean 17 | 18 | constructor({ cwd, executablePath }: FormatterOptions) { 19 | this._canFormat = true 20 | this.cwd = cwd || process.cwd() 21 | this.executablePath = executablePath 22 | } 23 | 24 | public get canFormat(): boolean { 25 | return this._canFormat 26 | } 27 | 28 | public async format( 29 | document: TextDocument, 30 | formatOptions?: LSP.FormattingOptions | null, 31 | shfmtConfig?: Record | null, 32 | ): Promise { 33 | if (!this._canFormat) { 34 | return [] 35 | } 36 | 37 | return this.executeFormat(document, formatOptions, shfmtConfig) 38 | } 39 | 40 | private async executeFormat( 41 | document: TextDocument, 42 | formatOptions?: LSP.FormattingOptions | null, 43 | shfmtConfig?: Record | null, 44 | ): Promise { 45 | const result = await this.runShfmt(document, formatOptions, shfmtConfig) 46 | 47 | if (!this._canFormat) { 48 | return [] 49 | } 50 | 51 | return [ 52 | { 53 | range: LSP.Range.create( 54 | LSP.Position.create(0, 0), 55 | LSP.Position.create(Number.MAX_VALUE, Number.MAX_VALUE), 56 | ), 57 | newText: result, 58 | }, 59 | ] 60 | } 61 | 62 | private async getShfmtArguments( 63 | documentUri: DocumentUri, 64 | formatOptions?: LSP.FormattingOptions | null, 65 | lspShfmtConfig?: Record | null, 66 | ): Promise { 67 | const args: string[] = [] 68 | 69 | // this is the config that we'll use to build args - default to language server config 70 | let activeShfmtConfig = { ...lspShfmtConfig } 71 | 72 | // do we have a document stored on the local filesystem? 73 | const filepathMatch = documentUri.match(/^file:\/\/(.*)$/) 74 | if (filepathMatch) { 75 | const filepath = filepathMatch[1] 76 | args.push(`--filename=${filepathMatch[1]}`) 77 | 78 | if (!lspShfmtConfig?.ignoreEditorconfig) { 79 | const editorconfigProperties = await editorconfig.parse(filepath) 80 | logger.debug( 81 | `Shfmt: found .editorconfig properties: ${JSON.stringify( 82 | editorconfigProperties, 83 | )}`, 84 | ) 85 | 86 | const editorconfigShfmtConfig: Record = {} 87 | editorconfigShfmtConfig.binaryNextLine = editorconfigProperties.binary_next_line 88 | editorconfigShfmtConfig.caseIndent = editorconfigProperties.switch_case_indent 89 | editorconfigShfmtConfig.funcNextLine = editorconfigProperties.function_next_line 90 | editorconfigShfmtConfig.keepPadding = editorconfigProperties.keep_padding 91 | // --simplify is not supported via .editorconfig 92 | editorconfigShfmtConfig.spaceRedirects = editorconfigProperties.space_redirects 93 | editorconfigShfmtConfig.languageDialect = editorconfigProperties.shell_variant 94 | 95 | // if we have any shfmt-specific options in .editorconfig, use the config in .editorconfig and 96 | // ignore the language server config (this is similar to shfmt's approach of using either 97 | // .editorconfig or command line flags, but not both) 98 | if ( 99 | editorconfigShfmtConfig.binaryNextLine !== undefined || 100 | editorconfigShfmtConfig.caseIndent !== undefined || 101 | editorconfigShfmtConfig.funcNextLine !== undefined || 102 | editorconfigShfmtConfig.keepPadding !== undefined || 103 | editorconfigShfmtConfig.spaceRedirects !== undefined || 104 | editorconfigShfmtConfig.languageDialect !== undefined 105 | ) { 106 | logger.debug( 107 | 'Shfmt: detected shfmt properties in .editorconfig - ignoring language server shfmt config', 108 | ) 109 | activeShfmtConfig = { ...editorconfigShfmtConfig } 110 | } else { 111 | logger.debug( 112 | 'Shfmt: no shfmt properties found in .editorconfig - using language server shfmt config', 113 | ) 114 | } 115 | } else { 116 | logger.debug( 117 | 'Shfmt: configured to ignore .editorconfig - using language server shfmt config', 118 | ) 119 | } 120 | } 121 | 122 | // indentation always comes via the editor - if someone is using .editorconfig then the 123 | // expectation is that they will have configured their editor's indentation in this way too 124 | const indentation: number = formatOptions?.insertSpaces ? formatOptions.tabSize : 0 125 | args.push(`-i=${indentation}`) // --indent 126 | 127 | if (activeShfmtConfig?.binaryNextLine) args.push('-bn') // --binary-next-line 128 | if (activeShfmtConfig?.caseIndent) args.push('-ci') // --case-indent 129 | if (activeShfmtConfig?.funcNextLine) args.push('-fn') // --func-next-line 130 | if (activeShfmtConfig?.keepPadding) args.push('-kp') // --keep-padding 131 | if (activeShfmtConfig?.simplifyCode) args.push('-s') // --simplify 132 | if (activeShfmtConfig?.spaceRedirects) args.push('-sr') // --space-redirects 133 | if (activeShfmtConfig?.languageDialect) 134 | args.push(`-ln=${activeShfmtConfig.languageDialect}`) // --language-dialect 135 | 136 | return args 137 | } 138 | 139 | private async runShfmt( 140 | document: TextDocument, 141 | formatOptions?: LSP.FormattingOptions | null, 142 | shfmtConfig?: Record | null, 143 | ): Promise { 144 | const args = await this.getShfmtArguments(document.uri, formatOptions, shfmtConfig) 145 | 146 | logger.debug(`Shfmt: running "${this.executablePath} ${args.join(' ')}"`) 147 | 148 | let out = '' 149 | let err = '' 150 | const proc = new Promise((resolve, reject) => { 151 | const proc = spawn(this.executablePath, [...args, '-'], { cwd: this.cwd }) 152 | proc.on('error', reject) 153 | proc.on('close', resolve) 154 | proc.stdout.on('data', (data) => (out += data)) 155 | proc.stderr.on('data', (data) => (err += data)) 156 | proc.stdin.on('error', () => { 157 | // NOTE: Ignore STDIN errors in case the process ends too quickly, before we try to 158 | // write. If we write after the process ends without this, we get an uncatchable EPIPE. 159 | // This is solved in Node >= 15.1 by the "on('spawn', ...)" event, but we need to 160 | // support earlier versions. 161 | }) 162 | proc.stdin.end(document.getText()) 163 | }) 164 | 165 | let exit 166 | try { 167 | exit = await proc 168 | } catch (e) { 169 | if ((e as any).code === 'ENOENT') { 170 | // shfmt path wasn't found, don't try to format any more: 171 | logger.warn( 172 | `Shfmt: disabling formatting as no executable was found at path '${this.executablePath}'`, 173 | ) 174 | this._canFormat = false 175 | return '' 176 | } 177 | throw new Error(`Shfmt: child process error: ${e}`) 178 | } 179 | 180 | if (exit != 0) { 181 | throw new Error(`Shfmt: exited with status ${exit}: ${err}`) 182 | } 183 | 184 | return out 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /server/src/types.ts: -------------------------------------------------------------------------------- 1 | import * as LSP from 'vscode-languageserver/node' 2 | 3 | export enum CompletionItemDataType { 4 | Builtin, 5 | Executable, 6 | ReservedWord, 7 | Symbol, 8 | Snippet, 9 | } 10 | 11 | export interface BashCompletionItem extends LSP.CompletionItem { 12 | data: { 13 | type: CompletionItemDataType 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /server/src/util/__tests__/__snapshots__/sourcing.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`getSourcedUris returns a set of sourced files (but ignores some unhandled cases) 2`] = ` 4 | [ 5 | { 6 | "error": null, 7 | "range": { 8 | "end": { 9 | "character": 28, 10 | "line": 1, 11 | }, 12 | "start": { 13 | "character": 6, 14 | "line": 1, 15 | }, 16 | }, 17 | "uri": "file:///Users/bash/file-in-path.sh", 18 | }, 19 | { 20 | "error": null, 21 | "range": { 22 | "end": { 23 | "character": 33, 24 | "line": 3, 25 | }, 26 | "start": { 27 | "character": 6, 28 | "line": 3, 29 | }, 30 | }, 31 | "uri": "file:///bin/extension.inc", 32 | }, 33 | { 34 | "error": null, 35 | "range": { 36 | "end": { 37 | "character": 22, 38 | "line": 5, 39 | }, 40 | "start": { 41 | "character": 6, 42 | "line": 5, 43 | }, 44 | }, 45 | "uri": "file:///Users/bash/x", 46 | }, 47 | { 48 | "error": null, 49 | "range": { 50 | "end": { 51 | "character": 36, 52 | "line": 7, 53 | }, 54 | "start": { 55 | "character": 6, 56 | "line": 7, 57 | }, 58 | }, 59 | "uri": "file:///Users/scripts/release-client.sh", 60 | }, 61 | { 62 | "error": null, 63 | "range": { 64 | "end": { 65 | "character": 23, 66 | "line": 9, 67 | }, 68 | "start": { 69 | "character": 6, 70 | "line": 9, 71 | }, 72 | }, 73 | "uri": "file:///Users/bash-user/myscript", 74 | }, 75 | { 76 | "error": "non-constant source not supported", 77 | "range": { 78 | "end": { 79 | "character": 23, 80 | "line": 13, 81 | }, 82 | "start": { 83 | "character": 6, 84 | "line": 13, 85 | }, 86 | }, 87 | "uri": null, 88 | }, 89 | { 90 | "error": null, 91 | "range": { 92 | "end": { 93 | "character": 39, 94 | "line": 15, 95 | }, 96 | "start": { 97 | "character": 6, 98 | "line": 15, 99 | }, 100 | }, 101 | "uri": "file:///Users/bash/issue-926.sh", 102 | }, 103 | { 104 | "error": "non-constant source not supported", 105 | "range": { 106 | "end": { 107 | "character": 66, 108 | "line": 18, 109 | }, 110 | "start": { 111 | "character": 49, 112 | "line": 18, 113 | }, 114 | }, 115 | "uri": null, 116 | }, 117 | { 118 | "error": "non-constant source not supported", 119 | "range": { 120 | "end": { 121 | "character": 66, 122 | "line": 20, 123 | }, 124 | "start": { 125 | "character": 6, 126 | "line": 20, 127 | }, 128 | }, 129 | "uri": null, 130 | }, 131 | { 132 | "error": null, 133 | "range": { 134 | "end": { 135 | "character": 30, 136 | "line": 27, 137 | }, 138 | "start": { 139 | "character": 8, 140 | "line": 27, 141 | }, 142 | }, 143 | "uri": "file:///Users/bash/issue206.sh", 144 | }, 145 | { 146 | "error": "non-constant source not supported", 147 | "range": { 148 | "end": { 149 | "character": 22, 150 | "line": 49, 151 | }, 152 | "start": { 153 | "character": 8, 154 | "line": 49, 155 | }, 156 | }, 157 | "uri": null, 158 | }, 159 | { 160 | "error": null, 161 | "range": { 162 | "end": { 163 | "character": 39, 164 | "line": 61, 165 | }, 166 | "start": { 167 | "character": 8, 168 | "line": 61, 169 | }, 170 | }, 171 | "uri": "file:///Users/bash/staging.sh", 172 | }, 173 | { 174 | "error": null, 175 | "range": { 176 | "end": { 177 | "character": 42, 178 | "line": 64, 179 | }, 180 | "start": { 181 | "character": 8, 182 | "line": 64, 183 | }, 184 | }, 185 | "uri": "file:///Users/bash/production.sh", 186 | }, 187 | { 188 | "error": "non-constant source not supported", 189 | "range": { 190 | "end": { 191 | "character": 67, 192 | "line": 91, 193 | }, 194 | "start": { 195 | "character": 10, 196 | "line": 91, 197 | }, 198 | }, 199 | "uri": null, 200 | }, 201 | ] 202 | `; 203 | -------------------------------------------------------------------------------- /server/src/util/__tests__/array.test.ts: -------------------------------------------------------------------------------- 1 | import { flattenArray, uniqueBasedOnHash } from '../array' 2 | 3 | describe('flattenArray', () => { 4 | it('works on array with one element', () => { 5 | expect(flattenArray([[1, 2, 3]])).toEqual([1, 2, 3]) 6 | }) 7 | 8 | it('works on array with multiple elements', () => { 9 | expect(flattenArray([[1], [2, 3], [4]])).toEqual([1, 2, 3, 4]) 10 | }) 11 | }) 12 | 13 | describe('uniqueBasedOnHash', () => { 14 | it('returns a list of unique elements', () => { 15 | type Item = { x: string; y: number } 16 | 17 | const items: Item[] = [ 18 | { x: '1', y: 1 }, 19 | { x: '1', y: 2 }, 20 | { x: '2', y: 3 }, 21 | ] 22 | 23 | const hashFunction = ({ x }: Item) => x 24 | const result = uniqueBasedOnHash(items, hashFunction) 25 | expect(result).toEqual([ 26 | { x: '1', y: 1 }, 27 | { x: '2', y: 3 }, 28 | ]) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /server/src/util/__tests__/logger.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import * as LSP from 'vscode-languageserver' 3 | 4 | import { getMockConnection } from '../../../../testing/mocks' 5 | import { 6 | getLogLevelFromEnvironment, 7 | LOG_LEVEL_ENV_VAR, 8 | Logger, 9 | setLogConnection, 10 | setLogLevel, 11 | } from '../logger' 12 | 13 | const mockConnection = getMockConnection() 14 | beforeAll(() => { 15 | setLogConnection(mockConnection) 16 | setLogLevel('info') 17 | jest.useFakeTimers().setSystemTime(1522431328992) 18 | }) 19 | 20 | jest.spyOn(console, 'warn').mockImplementation(() => { 21 | // noop 22 | }) 23 | 24 | describe('Logger', () => { 25 | it('logs simple message', () => { 26 | const logger = new Logger() 27 | logger.info('a test') 28 | logger.warn('another test') 29 | expect(mockConnection.sendNotification).toHaveBeenCalledTimes(2) 30 | expect(mockConnection.sendNotification).toHaveBeenNthCalledWith( 31 | 1, 32 | LSP.LogMessageNotification.type, 33 | { 34 | type: LSP.MessageType.Info, 35 | message: '17:35:28.992 INFO a test', 36 | }, 37 | ) 38 | expect(mockConnection.sendNotification).toHaveBeenNthCalledWith( 39 | 2, 40 | LSP.LogMessageNotification.type, 41 | { 42 | type: LSP.MessageType.Warning, 43 | message: '17:35:28.992 WARNING ⛔️ another test', 44 | }, 45 | ) 46 | }) 47 | 48 | it('allows parsing arguments', () => { 49 | const logger = new Logger({ prefix: 'myFunc' }) 50 | logger.info('foo', 'bar', 1) 51 | expect(mockConnection.sendNotification).toHaveBeenCalledTimes(1) 52 | expect(mockConnection.sendNotification.mock.calls[0][1]).toEqual({ 53 | type: LSP.MessageType.Info, 54 | message: '17:35:28.992 INFO myFunc - foo bar 1', 55 | }) 56 | }) 57 | 58 | it('allows parsing arguments with objects', () => { 59 | const logger = new Logger({ prefix: 'myFunc' }) 60 | logger.error('foo', { bar: 1 }) 61 | expect(mockConnection.sendNotification).toHaveBeenCalledTimes(1) 62 | expect(mockConnection.sendNotification.mock.calls[0][1]).toEqual({ 63 | type: LSP.MessageType.Error, 64 | message: '17:35:28.992 ERROR ⛔️ myFunc - foo {\n' + ' "bar": 1\n' + '}', 65 | }) 66 | }) 67 | 68 | it('handles Error', () => { 69 | const logger = new Logger() 70 | logger.error('msg', new Error('boom')) 71 | expect(mockConnection.sendNotification).toHaveBeenCalledTimes(1) 72 | expect(mockConnection.sendNotification.mock.calls[0][1]).toEqual({ 73 | type: LSP.MessageType.Error, 74 | message: expect.stringContaining('17:35:28.992 ERROR ⛔️ msg Error: boom'), 75 | }) 76 | }) 77 | }) 78 | 79 | describe('getLogLevelFromEnvironment', () => { 80 | it('returns default log level if no environment variable is set', () => { 81 | expect(getLogLevelFromEnvironment()).toEqual(LSP.MessageType.Info) 82 | }) 83 | 84 | it('returns default log level if environment variable is set to an invalid value', () => { 85 | process.env[LOG_LEVEL_ENV_VAR] = 'invalid' 86 | expect(getLogLevelFromEnvironment()).toEqual(LSP.MessageType.Info) 87 | 88 | expect(console.warn).toHaveBeenCalledTimes(1) 89 | expect(console.warn).toHaveBeenCalledWith( 90 | `Invalid BASH_IDE_LOG_LEVEL "invalid", expected one of: debug, info, warning, error`, 91 | ) 92 | }) 93 | 94 | it('returns log level from environment variable', () => { 95 | process.env[LOG_LEVEL_ENV_VAR] = 'debug' 96 | expect(getLogLevelFromEnvironment()).toEqual(LSP.MessageType.Log) 97 | }) 98 | }) 99 | -------------------------------------------------------------------------------- /server/src/util/__tests__/shebang.test.ts: -------------------------------------------------------------------------------- 1 | import { FIXTURE_DOCUMENT } from '../../../../testing/fixtures' 2 | import { analyzeShebang } from '../shebang' 3 | 4 | describe('analyzeShebang', () => { 5 | it('returns null for an empty file', () => { 6 | expect(analyzeShebang('')).toEqual({ shellDialect: null, shebang: null }) 7 | }) 8 | 9 | it('returns no shell dialect for python files', () => { 10 | expect(analyzeShebang(`#!/usr/bin/env python2.7\n# set -x`)).toEqual({ 11 | shellDialect: null, 12 | shebang: '/usr/bin/env python2.7', 13 | }) 14 | }) 15 | 16 | it('returns no shell dialect for unsupported shell "#!/usr/bin/fish"', () => { 17 | expect(analyzeShebang('#!/usr/bin/fish')).toEqual({ 18 | shellDialect: null, 19 | shebang: '/usr/bin/fish', 20 | }) 21 | }) 22 | 23 | test.each([ 24 | ['#!/bin/sh -', 'sh'], 25 | ['#!/bin/sh', 'sh'], 26 | ['#!/bin/env sh', 'sh'], 27 | ['#!/usr/bin/env bash', 'bash'], 28 | ['#!/bin/env bash', 'bash'], 29 | ['#!/bin/bash', 'bash'], 30 | ['#!/bin/bash -u', 'bash'], 31 | ['#! /bin/bash', 'bash'], 32 | ['#! /bin/dash', 'dash'], 33 | ['#!/usr/bin/bash', 'bash'], 34 | ['#!/usr/bin/zsh', 'zsh'], 35 | ])('returns a bash dialect for %p', (command, expectedDialect) => { 36 | expect(analyzeShebang(command).shellDialect).toBe(expectedDialect) 37 | expect(analyzeShebang(`${command} `).shellDialect).toBe(expectedDialect) 38 | }) 39 | 40 | it('returns shell dialect from shell directive', () => { 41 | expect(analyzeShebang('# shellcheck shell=dash')).toEqual({ 42 | shellDialect: 'dash', 43 | shebang: null, 44 | }) 45 | }) 46 | 47 | it('returns shell dialect when multiple directives are passed', () => { 48 | expect( 49 | analyzeShebang( 50 | '# shellcheck enable=require-variable-braces shell=dash disable=SC1000', 51 | ), 52 | ).toEqual({ 53 | shellDialect: 'dash', 54 | shebang: null, 55 | }) 56 | }) 57 | 58 | it('shell directive overrides file extension and shebang', () => { 59 | expect( 60 | analyzeShebang(FIXTURE_DOCUMENT.SHELLCHECK_SHELL_DIRECTIVE.getText()), 61 | ).toHaveProperty('shellDialect', 'sh') 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /server/src/util/__tests__/sourcing.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as os from 'os' 3 | import * as Parser from 'web-tree-sitter' 4 | 5 | import { REPO_ROOT_FOLDER } from '../../../../testing/fixtures' 6 | import { initializeParser } from '../../parser' 7 | import { getSourceCommands } from '../sourcing' 8 | 9 | const fileDirectory = '/Users/bash' 10 | const fileUri = `${fileDirectory}/file.sh` 11 | 12 | let parser: Parser 13 | beforeAll(async () => { 14 | parser = await initializeParser() 15 | }) 16 | 17 | // mock os.homedir() to return a fixed path 18 | jest.spyOn(os, 'homedir').mockImplementation(() => '/Users/bash-user') 19 | 20 | describe('getSourcedUris', () => { 21 | it('returns an empty set if no files were sourced', () => { 22 | const fileContent = '' 23 | const sourceCommands = getSourceCommands({ 24 | fileUri, 25 | rootPath: null, 26 | tree: parser.parse(fileContent), 27 | }) 28 | expect(sourceCommands).toEqual([]) 29 | }) 30 | 31 | it('returns a set of sourced files (but ignores some unhandled cases)', () => { 32 | jest.spyOn(fs, 'existsSync').mockImplementation(() => true) 33 | 34 | const fileContent = ` 35 | source file-in-path.sh # does not contain a slash (i.e. is maybe somewhere on the path) 36 | 37 | source '/bin/extension.inc' # absolute path 38 | 39 | source ./x a b c # some arguments 40 | 41 | . ../scripts/release-client.sh 42 | 43 | source ~/myscript 44 | 45 | # source ... 46 | 47 | source "$LIBPATH" # dynamic imports not supported 48 | 49 | source "$SCRIPT_DIR"/issue-926.sh # remove leading dynamic segment 50 | 51 | # conditional is currently not supported 52 | if [[ -z $__COMPLETION_LIB_LOADED ]]; then source "$LIBPATH" ; fi 53 | 54 | . "$BASH_IT/themes/$BASH_IT_THEME/$BASH_IT_THEME.theme.bash" 55 | 56 | show () 57 | { 58 | about 'Shows SVN proxy settings' 59 | group 'proxy' 60 | 61 | source "./issue206.sh" # quoted file in fixtures folder 62 | 63 | echo "SVN Proxy Settings" 64 | echo "==================" 65 | python2 - < $counts_file 75 | fi 76 | done 77 | 78 | # ====================================== 79 | # Example of sourcing through a function 80 | # ====================================== 81 | 82 | loadlib () { 83 | source "$1.sh" 84 | } 85 | 86 | loadlib "issue101" 87 | 88 | # ====================================== 89 | # Example of dynamic sourcing 90 | # ====================================== 91 | 92 | SCRIPT_DIR=$( cd "$( dirname "\${BASH_SOURCE[0]}" )" && pwd ) 93 | case "$ENV" in 94 | staging) 95 | source "$SCRIPT_DIR"/staging.sh 96 | ;; 97 | production) 98 | source "$SCRIPT_DIR"/production.sh 99 | ;; 100 | *) 101 | echo "Unknown environment please use 'staging' or 'production'" 102 | exit 1 103 | ;; 104 | esac 105 | 106 | # ====================================== 107 | # Example of sourcing through a loop 108 | # ====================================== 109 | 110 | # Only set $BASH_IT if it's not already set 111 | if [ -z "$BASH_IT" ]; 112 | then 113 | # Setting $BASH to maintain backwards compatibility 114 | # TODO: warn users that they should upgrade their .bash_profile 115 | export BASH_IT=$BASH 116 | export BASH="$(bash -c 'echo $BASH')" 117 | fi 118 | 119 | # Load custom aliases, completion, plugins 120 | for file_type in "aliases" "completion" "plugins" 121 | do 122 | if [ -e "\${BASH_IT}/\${file_type}/custom.\${file_type}.bash" ] 123 | then 124 | # shellcheck disable=SC1090 125 | source "\${BASH_IT}/\${file_type}/custom.\${file_type}.bash" 126 | fi 127 | done 128 | ` 129 | 130 | const sourceCommands = getSourceCommands({ 131 | fileUri, 132 | rootPath: null, 133 | tree: parser.parse(fileContent), 134 | }) 135 | 136 | const sourcedUris = new Set( 137 | sourceCommands 138 | .map((sourceCommand) => sourceCommand.uri) 139 | .filter((uri) => uri !== null), 140 | ) 141 | 142 | expect(sourcedUris).toMatchInlineSnapshot(` 143 | Set { 144 | "file:///Users/bash/file-in-path.sh", 145 | "file:///bin/extension.inc", 146 | "file:///Users/bash/x", 147 | "file:///Users/scripts/release-client.sh", 148 | "file:///Users/bash-user/myscript", 149 | "file:///Users/bash/issue-926.sh", 150 | "file:///Users/bash/issue206.sh", 151 | "file:///Users/bash/staging.sh", 152 | "file:///Users/bash/production.sh", 153 | } 154 | `) 155 | 156 | expect(sourceCommands).toMatchSnapshot() 157 | }) 158 | 159 | it('returns a set of sourced files and parses ShellCheck directives', () => { 160 | jest.restoreAllMocks() 161 | 162 | const fileContent = ` 163 | . ./scripts/release-client.sh 164 | 165 | source ./testing/fixtures/issue206.sh 166 | 167 | # shellcheck source=/dev/null 168 | source ./IM_NOT_THERE.sh 169 | 170 | # shellcheck source-path=testing/fixtures 171 | source missing-node.sh # source path by directive 172 | 173 | # shellcheck source=./testing/fixtures/install.sh 174 | source "$X" # source by directive 175 | 176 | # shellcheck source=./some-file-that-does-not-exist.sh 177 | source "$Y" # not source due to invalid directive 178 | 179 | # shellcheck source-path=SCRIPTDIR # note that this is already the behaviour of bash language server 180 | source ./testing/fixtures/issue101.sh 181 | 182 | source # not finished 183 | ` 184 | 185 | const sourceCommands = getSourceCommands({ 186 | fileUri, 187 | rootPath: REPO_ROOT_FOLDER, 188 | tree: parser.parse(fileContent), 189 | }) 190 | 191 | const sourcedUris = new Set( 192 | sourceCommands 193 | .map((sourceCommand) => sourceCommand.uri) 194 | .filter((uri) => uri !== null), 195 | ) 196 | 197 | expect(sourcedUris).toEqual( 198 | new Set([ 199 | `file://${REPO_ROOT_FOLDER}/scripts/release-client.sh`, 200 | `file://${REPO_ROOT_FOLDER}/testing/fixtures/issue206.sh`, 201 | `file://${REPO_ROOT_FOLDER}/testing/fixtures/missing-node.sh`, 202 | `file://${REPO_ROOT_FOLDER}/testing/fixtures/install.sh`, 203 | `file://${REPO_ROOT_FOLDER}/testing/fixtures/issue101.sh`, 204 | ]), 205 | ) 206 | 207 | expect( 208 | sourceCommands 209 | .filter((command) => command.error) 210 | .map(({ error, range }) => ({ 211 | error, 212 | line: range.start.line, 213 | })), 214 | ).toMatchInlineSnapshot(` 215 | [ 216 | { 217 | "error": "failed to resolve path", 218 | "line": 15, 219 | }, 220 | ] 221 | `) 222 | }) 223 | }) 224 | -------------------------------------------------------------------------------- /server/src/util/array.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Flatten a 2-dimensional array into a 1-dimensional one. 3 | */ 4 | export function flattenArray(nestedArray: T[][]): T[] { 5 | return nestedArray.reduce((acc, array) => [...acc, ...array], []) 6 | } 7 | 8 | /** 9 | * Remove all duplicates from the list. 10 | * Doesn't preserve ordering. 11 | */ 12 | export function uniq(a: A[]): A[] { 13 | return Array.from(new Set(a)) 14 | } 15 | 16 | /** 17 | * Removed all duplicates from the list based on the hash function. 18 | * First element matching the hash function wins. 19 | */ 20 | export function uniqueBasedOnHash>( 21 | list: A[], 22 | elementToHash: (a: A) => string, 23 | __result: A[] = [], 24 | ): A[] { 25 | const result: typeof list = [] 26 | const hashSet = new Set() 27 | 28 | list.forEach((element) => { 29 | const hash = elementToHash(element) 30 | if (hashSet.has(hash)) { 31 | return 32 | } 33 | hashSet.add(hash) 34 | result.push(element) 35 | }) 36 | 37 | return result 38 | } 39 | -------------------------------------------------------------------------------- /server/src/util/async.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A collection of async utilities. 3 | * If we ever need to add anything fancy, then https://github.com/vscode-shellcheck/vscode-shellcheck/blob/master/src/utils/async.ts 4 | * is a good place to look. 5 | */ 6 | 7 | type UnwrapPromise = T extends Promise ? U : T 8 | 9 | /** 10 | * Debounce a function call by a given amount of time. Only the last call 11 | * will be resolved. 12 | * Inspired by https://gist.github.com/ca0v/73a31f57b397606c9813472f7493a940 13 | */ 14 | export const debounce = any>( 15 | func: F, 16 | waitForMs: number, 17 | ) => { 18 | let timeout: ReturnType | null = null 19 | 20 | return (...args: Parameters): Promise>> => 21 | new Promise((resolve) => { 22 | if (timeout) { 23 | clearTimeout(timeout) 24 | } 25 | 26 | timeout = setTimeout(() => resolve(func(...args)), waitForMs) 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /server/src/util/discriminate.ts: -------------------------------------------------------------------------------- 1 | export function discriminate( 2 | discriminantKey: K, 3 | discriminantValue: V, 4 | ) { 5 | return >( 6 | obj: T & Record, 7 | ): obj is Extract> => obj[discriminantKey] === discriminantValue 8 | } 9 | -------------------------------------------------------------------------------- /server/src/util/fs.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'node:os' 2 | import { fileURLToPath } from 'node:url' 3 | 4 | import * as fastGlob from 'fast-glob' 5 | 6 | // from https://github.com/sindresorhus/untildify/blob/f85a087418aeaa2beb56fe2684fe3b64fc8c588d/index.js#L11 7 | export function untildify(pathWithTilde: string): string { 8 | const homeDirectory = os.homedir() 9 | return homeDirectory 10 | ? pathWithTilde.replace(/^~(?=$|\/|\\)/, homeDirectory) 11 | : pathWithTilde 12 | } 13 | 14 | export async function getFilePaths({ 15 | globPattern, 16 | rootPath, 17 | maxItems, 18 | }: { 19 | globPattern: string 20 | rootPath: string 21 | maxItems: number 22 | }): Promise { 23 | if (rootPath.startsWith('file://')) { 24 | rootPath = fileURLToPath(rootPath) 25 | } 26 | 27 | const stream = fastGlob.stream([globPattern], { 28 | absolute: true, 29 | onlyFiles: true, 30 | cwd: rootPath, 31 | followSymbolicLinks: true, 32 | suppressErrors: true, 33 | }) 34 | 35 | // NOTE: we use a stream here to not block the event loop 36 | // and ensure that we stop reading files if the glob returns 37 | // too many files. 38 | const files = [] 39 | let i = 0 40 | for await (const fileEntry of stream) { 41 | if (i >= maxItems) { 42 | // NOTE: Close the stream to stop reading files paths. 43 | stream.emit('close') 44 | break 45 | } 46 | 47 | files.push(fileEntry.toString()) 48 | i++ 49 | } 50 | 51 | return files 52 | } 53 | -------------------------------------------------------------------------------- /server/src/util/logger.ts: -------------------------------------------------------------------------------- 1 | import * as LSP from 'vscode-languageserver' 2 | 3 | export const LOG_LEVEL_ENV_VAR = 'BASH_IDE_LOG_LEVEL' 4 | export const LOG_LEVELS = ['debug', 'info', 'warning', 'error'] as const 5 | export const DEFAULT_LOG_LEVEL: LogLevel = 'info' 6 | 7 | type LogLevel = (typeof LOG_LEVELS)[number] 8 | 9 | const LOG_LEVELS_TO_MESSAGE_TYPES: { 10 | [logLevel in LogLevel]: LSP.MessageType 11 | } = { 12 | debug: LSP.MessageType.Log, 13 | info: LSP.MessageType.Info, 14 | warning: LSP.MessageType.Warning, 15 | error: LSP.MessageType.Error, 16 | } as const 17 | 18 | // Singleton madness to allow for logging from anywhere in the codebase 19 | let _connection: LSP.Connection | null = null 20 | let _logLevel: LSP.MessageType = getLogLevelFromEnvironment() 21 | 22 | /** 23 | * Set the log connection. Should be done at startup. 24 | */ 25 | export function setLogConnection(connection: LSP.Connection) { 26 | _connection = connection 27 | } 28 | 29 | /** 30 | * Set the minimum log level. 31 | */ 32 | export function setLogLevel(logLevel: LogLevel) { 33 | _logLevel = LOG_LEVELS_TO_MESSAGE_TYPES[logLevel] 34 | } 35 | 36 | export class Logger { 37 | private prefix: string 38 | 39 | constructor({ prefix = '' }: { prefix?: string } = {}) { 40 | this.prefix = prefix 41 | } 42 | 43 | static MESSAGE_TYPE_TO_LOG_LEVEL_MSG: Record = { 44 | [LSP.MessageType.Error]: 'ERROR ⛔️', 45 | [LSP.MessageType.Warning]: 'WARNING ⛔️', 46 | [LSP.MessageType.Info]: 'INFO', 47 | [LSP.MessageType.Log]: 'DEBUG', 48 | } 49 | 50 | public log(severity: LSP.MessageType, messageObjects: any[]) { 51 | if (_logLevel < severity) { 52 | return 53 | } 54 | 55 | if (!_connection) { 56 | // eslint-disable-next-line no-console 57 | console.warn(`The logger's LSP Connection is not set. Dropping messages`) 58 | return 59 | } 60 | 61 | const formattedMessage = messageObjects 62 | .map((p) => { 63 | if (p instanceof Error) { 64 | return p.stack || p.message 65 | } 66 | 67 | if (typeof p === 'object') { 68 | return JSON.stringify(p, null, 2) 69 | } 70 | 71 | return p 72 | }) 73 | .join(' ') 74 | 75 | const level = Logger.MESSAGE_TYPE_TO_LOG_LEVEL_MSG[severity] 76 | const prefix = this.prefix ? `${this.prefix} - ` : '' 77 | const time = new Date().toISOString().substring(11, 23) 78 | const message = `${time} ${level} ${prefix}${formattedMessage}` 79 | 80 | _connection.sendNotification(LSP.LogMessageNotification.type, { 81 | type: severity, 82 | message, 83 | }) 84 | } 85 | 86 | public debug(message: string, ...additionalArgs: any[]) { 87 | this.log(LSP.MessageType.Log, [message, ...additionalArgs]) 88 | } 89 | public info(message: string, ...additionalArgs: any[]) { 90 | this.log(LSP.MessageType.Info, [message, ...additionalArgs]) 91 | } 92 | public warn(message: string, ...additionalArgs: any[]) { 93 | this.log(LSP.MessageType.Warning, [message, ...additionalArgs]) 94 | } 95 | public error(message: string, ...additionalArgs: any[]) { 96 | this.log(LSP.MessageType.Error, [message, ...additionalArgs]) 97 | } 98 | } 99 | 100 | /** 101 | * Default logger. 102 | */ 103 | export const logger = new Logger() 104 | 105 | /** 106 | * Get the log level from the environment, before the server initializes. 107 | * Should only be used internally. 108 | */ 109 | export function getLogLevelFromEnvironment(): LSP.MessageType { 110 | const logLevelFromEnvironment = process.env[LOG_LEVEL_ENV_VAR] as LogLevel | undefined 111 | if (logLevelFromEnvironment) { 112 | const logLevel = LOG_LEVELS_TO_MESSAGE_TYPES[logLevelFromEnvironment] 113 | if (logLevel) { 114 | return logLevel 115 | } 116 | // eslint-disable-next-line no-console 117 | console.warn( 118 | `Invalid ${LOG_LEVEL_ENV_VAR} "${logLevelFromEnvironment}", expected one of: ${Object.keys( 119 | LOG_LEVELS_TO_MESSAGE_TYPES, 120 | ).join(', ')}`, 121 | ) 122 | } 123 | 124 | return LOG_LEVELS_TO_MESSAGE_TYPES[DEFAULT_LOG_LEVEL] 125 | } 126 | -------------------------------------------------------------------------------- /server/src/util/lsp.ts: -------------------------------------------------------------------------------- 1 | import * as LSP from 'vscode-languageserver/node' 2 | 3 | /** 4 | * Determine if a position is included in a range. 5 | */ 6 | export function isPositionIncludedInRange(position: LSP.Position, range: LSP.Range) { 7 | return ( 8 | range.start.line <= position.line && 9 | range.end.line >= position.line && 10 | range.start.character <= position.character && 11 | range.end.character >= position.character 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /server/src/util/platform.ts: -------------------------------------------------------------------------------- 1 | export function isWindows() { 2 | return process.platform === 'win32' 3 | } 4 | -------------------------------------------------------------------------------- /server/src/util/sh.ts: -------------------------------------------------------------------------------- 1 | import * as ChildProcess from 'child_process' 2 | 3 | import { logger } from './logger' 4 | import { isWindows } from './platform' 5 | 6 | /** 7 | * Execute the following sh program. 8 | */ 9 | export function execShellScript( 10 | body: string, 11 | cmd = isWindows() ? 'cmd.exe' : 'bash', 12 | ): Promise { 13 | const args = [] 14 | 15 | if (cmd === 'cmd.exe') { 16 | args.push('/c', body) 17 | } else { 18 | args.push('--noprofile', '--norc', '-c', body) 19 | } 20 | 21 | const process = ChildProcess.spawn(cmd, args) 22 | 23 | return new Promise((resolve, reject) => { 24 | let output = '' 25 | 26 | const handleClose = (returnCode: number | Error) => { 27 | if (returnCode === 0) { 28 | resolve(output) 29 | } else { 30 | reject(`Failed to execute ${body}`) 31 | } 32 | } 33 | 34 | process.stdout.on('data', (buffer) => { 35 | output += buffer 36 | }) 37 | 38 | process.on('close', handleClose) 39 | process.on('error', handleClose) 40 | }) 41 | } 42 | 43 | // Currently only reserved words where documentation doesn't make sense. 44 | // At least on OS X these just return the builtin man. On ubuntu there 45 | // are no documentation for them. 46 | const WORDS_WITHOUT_DOCUMENTATION = new Set([ 47 | 'else', 48 | 'fi', 49 | 'then', 50 | 'esac', 51 | 'elif', 52 | 'done', 53 | ]) 54 | 55 | /** 56 | * Get documentation for the given word by using help and man. 57 | */ 58 | export async function getShellDocumentationWithoutCache({ 59 | word, 60 | }: { 61 | word: string 62 | }): Promise { 63 | if (word.split(' ').length > 1) { 64 | throw new Error(`lookupDocumentation should be given a word, received "${word}"`) 65 | } 66 | 67 | if (WORDS_WITHOUT_DOCUMENTATION.has(word)) { 68 | return null 69 | } 70 | 71 | const DOCUMENTATION_COMMANDS = [ 72 | { type: 'help', command: `help ${word} | col -bx` }, 73 | // We have experimented with setting MANWIDTH to different values for reformatting. 74 | // The default line width of the terminal works fine for hover, but could be better 75 | // for completions. 76 | { type: 'man', command: `man -P cat ${word} | col -bx` }, 77 | ] 78 | 79 | for (const { type, command } of DOCUMENTATION_COMMANDS) { 80 | try { 81 | const documentation = await execShellScript(command) 82 | if (documentation) { 83 | let formattedDocumentation = documentation.trim() 84 | 85 | if (type === 'man') { 86 | formattedDocumentation = formatManOutput(formattedDocumentation) 87 | } 88 | 89 | if (formattedDocumentation) { 90 | return formattedDocumentation 91 | } 92 | } 93 | } catch (error) { 94 | // Ignoring if command fails and store failure in cache 95 | logger.error(`getShellDocumentation failed for "${word}"`, error) 96 | } 97 | } 98 | 99 | return null 100 | } 101 | 102 | export function formatManOutput(manOutput: string): string { 103 | const indexNameBlock = manOutput.indexOf('NAME') 104 | const indexBeforeFooter = manOutput.lastIndexOf('\n') 105 | 106 | if (indexNameBlock < 0 || indexBeforeFooter < 0) { 107 | return manOutput 108 | } 109 | 110 | const formattedManOutput = manOutput.slice(indexNameBlock, indexBeforeFooter) 111 | 112 | if (!formattedManOutput) { 113 | logger.error(`formatManOutput failed`, { manOutput }) 114 | return manOutput 115 | } 116 | 117 | return formattedManOutput 118 | } 119 | 120 | /** 121 | * Only works for one-parameter (serializable) functions. 122 | */ 123 | /* eslint-disable @typescript-eslint/ban-types */ 124 | export function memorize(func: T): T { 125 | const cache = new Map() 126 | 127 | const returnFunc = async function (arg: any) { 128 | const cacheKey = JSON.stringify(arg) 129 | 130 | if (cache.has(cacheKey)) { 131 | return cache.get(cacheKey) 132 | } 133 | 134 | const result = await func(arg) 135 | 136 | cache.set(cacheKey, result) 137 | return result 138 | } 139 | 140 | return returnFunc as any 141 | } 142 | 143 | export const getShellDocumentation = memorize(getShellDocumentationWithoutCache) 144 | -------------------------------------------------------------------------------- /server/src/util/shebang.ts: -------------------------------------------------------------------------------- 1 | const SHEBANG_REGEXP = /^#!(.*)/ 2 | const SHELL_REGEXP = /bin[/](?:env )?(\w+)/ 3 | 4 | // Non exhaustive list of bash dialects that we potentially could support and try to analyze. 5 | const BASH_DIALECTS = ['sh', 'bash', 'dash', 'ksh', 'zsh', 'csh', 'ash'] as const 6 | type BashDialect = (typeof BASH_DIALECTS)[number] 7 | 8 | const SHELL_DIRECTIVE_REGEXP = new RegExp( 9 | `^\\s*#\\s*shellcheck.*shell=(${BASH_DIALECTS.join('|')}).*$|^\\s*#.*$|^\\s*$`, 10 | ) 11 | 12 | export function getShebang(fileContent: string): string | null { 13 | const match = SHEBANG_REGEXP.exec(fileContent) 14 | if (!match || !match[1]) { 15 | return null 16 | } 17 | 18 | return match[1].trim() 19 | } 20 | 21 | export function getShellDialect(shebang: string): BashDialect | null { 22 | const match = SHELL_REGEXP.exec(shebang) 23 | if (match && match[1]) { 24 | const bashDialect = match[1].trim() as any 25 | if (BASH_DIALECTS.includes(bashDialect)) { 26 | return bashDialect 27 | } 28 | } 29 | 30 | return null 31 | } 32 | 33 | export function getShellDialectFromShellDirective( 34 | fileContent: string, 35 | ): BashDialect | null { 36 | const contentLines = fileContent.split('\n') 37 | for (const line of contentLines) { 38 | const match = SHELL_DIRECTIVE_REGEXP.exec(line) 39 | if (match === null) { 40 | break 41 | } 42 | if (match[1]) { 43 | const bashDialect = match[1].trim() as any 44 | if (BASH_DIALECTS.includes(bashDialect)) { 45 | return bashDialect 46 | } 47 | } 48 | } 49 | return null 50 | } 51 | 52 | export function analyzeShebang(fileContent: string): { 53 | shellDialect: BashDialect | null 54 | shebang: string | null 55 | } { 56 | const shebang = getShebang(fileContent) 57 | return { 58 | shebang, 59 | shellDialect: 60 | getShellDialectFromShellDirective(fileContent) ?? 61 | (shebang ? getShellDialect(shebang) : null), 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /server/src/util/sourcing.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | import * as LSP from 'vscode-languageserver' 4 | import * as Parser from 'web-tree-sitter' 5 | 6 | import { parseShellCheckDirective } from '../shellcheck/directive' 7 | import { discriminate } from './discriminate' 8 | import { untildify } from './fs' 9 | import * as TreeSitterUtil from './tree-sitter' 10 | 11 | const SOURCING_COMMANDS = ['source', '.'] 12 | 13 | export type SourceCommand = { 14 | range: LSP.Range 15 | uri: string | null // resolved URIs 16 | error: string | null 17 | } 18 | 19 | /** 20 | * Analysis the given tree for source commands. 21 | */ 22 | export function getSourceCommands({ 23 | fileUri, 24 | rootPath, 25 | tree, 26 | }: { 27 | fileUri: string 28 | rootPath: string | null 29 | tree: Parser.Tree 30 | }): SourceCommand[] { 31 | const sourceCommands: SourceCommand[] = [] 32 | 33 | const rootPaths = [path.dirname(fileUri), rootPath].filter(Boolean) as string[] 34 | 35 | TreeSitterUtil.forEach(tree.rootNode, (node) => { 36 | const sourcedPathInfo = getSourcedPathInfoFromNode({ node }) 37 | 38 | if (sourcedPathInfo) { 39 | const { sourcedPath, parseError } = sourcedPathInfo 40 | const uri = sourcedPath ? resolveSourcedUri({ rootPaths, sourcedPath }) : null 41 | 42 | sourceCommands.push({ 43 | range: TreeSitterUtil.range(node), 44 | uri, 45 | error: uri ? null : parseError || 'failed to resolve path', 46 | }) 47 | } 48 | 49 | return true 50 | }) 51 | 52 | return sourceCommands 53 | } 54 | 55 | function getSourcedPathInfoFromNode({ 56 | node, 57 | }: { 58 | node: Parser.SyntaxNode 59 | }): null | { sourcedPath?: string; parseError?: string } { 60 | if (node.type === 'command') { 61 | const [commandNameNode, argumentNode] = node.namedChildren 62 | 63 | if (!commandNameNode || !argumentNode) { 64 | return null 65 | } 66 | 67 | if ( 68 | commandNameNode.type === 'command_name' && 69 | SOURCING_COMMANDS.includes(commandNameNode.text) 70 | ) { 71 | const previousCommentNode = 72 | node.previousSibling?.type === 'comment' ? node.previousSibling : null 73 | 74 | if (previousCommentNode?.text.includes('shellcheck')) { 75 | const directives = parseShellCheckDirective(previousCommentNode.text) 76 | const sourcedPath = directives.find(discriminate('type', 'source'))?.path 77 | 78 | if (sourcedPath === '/dev/null') { 79 | return null 80 | } 81 | 82 | if (sourcedPath) { 83 | return { 84 | sourcedPath, 85 | } 86 | } 87 | 88 | const isNotFollowErrorDisabled = !!directives 89 | .filter(discriminate('type', 'disable')) 90 | .flatMap(({ rules }) => rules) 91 | .find((rule) => rule === 'SC1091') 92 | 93 | if (isNotFollowErrorDisabled) { 94 | return null 95 | } 96 | 97 | const rootFolder = directives.find(discriminate('type', 'source-path'))?.path 98 | if (rootFolder && rootFolder !== 'SCRIPTDIR' && argumentNode.type === 'word') { 99 | return { 100 | sourcedPath: path.join(rootFolder, argumentNode.text), 101 | } 102 | } 103 | } 104 | 105 | const strValue = TreeSitterUtil.resolveStaticString(argumentNode) 106 | if (strValue !== null) { 107 | return { 108 | sourcedPath: strValue, 109 | } 110 | } 111 | 112 | // Strip one leading dynamic section. 113 | if (argumentNode.type === 'string' && argumentNode.namedChildren.length === 1) { 114 | const [variableNode] = argumentNode.namedChildren 115 | if (TreeSitterUtil.isExpansion(variableNode)) { 116 | const stringContents = argumentNode.text.slice(1, -1) 117 | if (stringContents.startsWith(`${variableNode.text}/`)) { 118 | return { 119 | sourcedPath: `.${stringContents.slice(variableNode.text.length)}`, 120 | } 121 | } 122 | } 123 | } 124 | 125 | if (argumentNode.type === 'concatenation') { 126 | // Strip one leading dynamic section from a concatenation node. 127 | const sourcedPath = resolveSourceFromConcatenation(argumentNode) 128 | if (sourcedPath) { 129 | return { 130 | sourcedPath, 131 | } 132 | } 133 | } 134 | 135 | // TODO: we could try to parse any ShellCheck "source "directive 136 | // # shellcheck source=src/examples/config.sh 137 | return { 138 | parseError: `non-constant source not supported`, 139 | } 140 | } 141 | } 142 | 143 | return null 144 | } 145 | 146 | /** 147 | * Tries to resolve the given sourced path and returns a URI if possible. 148 | * - Converts a relative paths to absolute paths 149 | * - Converts a tilde path to an absolute path 150 | * - Resolves the path 151 | * 152 | * NOTE: for future improvements: 153 | * "If filename does not contain a slash, file names in PATH are used to find 154 | * the directory containing filename." (see https://ss64.com/osx/source.html) 155 | */ 156 | function resolveSourcedUri({ 157 | rootPaths, 158 | sourcedPath, 159 | }: { 160 | rootPaths: string[] 161 | sourcedPath: string 162 | }): string | null { 163 | if (sourcedPath.startsWith('~')) { 164 | sourcedPath = untildify(sourcedPath) 165 | } 166 | 167 | if (sourcedPath.startsWith('/')) { 168 | if (fs.existsSync(sourcedPath)) { 169 | return `file://${sourcedPath}` 170 | } 171 | return null 172 | } 173 | 174 | // resolve relative path 175 | for (const rootPath of rootPaths) { 176 | const potentialPath = path.join(rootPath.replace('file://', ''), sourcedPath) 177 | 178 | // check if path is a file 179 | if (fs.existsSync(potentialPath)) { 180 | return `file://${potentialPath}` 181 | } 182 | } 183 | 184 | return null 185 | } 186 | 187 | /* 188 | * Resolves the source path from a concatenation node, stripping a leading dynamic directory segment. 189 | * Returns null if the source path can't be statically determined after stripping a segment. 190 | * Note: If a non-concatenation node is passed, null will be returned. This is likely a programmer error. 191 | */ 192 | function resolveSourceFromConcatenation(node: Parser.SyntaxNode): string | null { 193 | if (node.type !== 'concatenation') return null 194 | const stringValue = TreeSitterUtil.resolveStaticString(node) 195 | if (stringValue !== null) return stringValue // This string is fully static. 196 | 197 | const values: string[] = [] 198 | // Since the string must begin with the variable, the variable must be in the first child. 199 | const [firstNode, ...rest] = node.namedChildren 200 | // The first child is static, this means one of the other children is not! 201 | if (TreeSitterUtil.resolveStaticString(firstNode) !== null) return null 202 | 203 | // if the string is unquoted, the first child is the variable, so there's no more text in it. 204 | if (!TreeSitterUtil.isExpansion(firstNode)) { 205 | if (firstNode.namedChildCount > 1) return null // Only one variable is allowed. 206 | // Since the string must begin with the variable, the variable must be first child. 207 | const variableNode = firstNode.namedChildren[0] // Get the variable (quoted case) 208 | // This is command substitution! 209 | if (!TreeSitterUtil.isExpansion(variableNode)) return null 210 | const stringContents = firstNode.text.slice(1, -1) 211 | // The string doesn't start with the variable! 212 | if (!stringContents.startsWith(variableNode.text)) return null 213 | // Get the remaining static portion the string 214 | values.push(stringContents.slice(variableNode.text.length)) 215 | } 216 | 217 | for (const child of rest) { 218 | const value = TreeSitterUtil.resolveStaticString(child) 219 | // The other values weren't statically determinable! 220 | if (value === null) return null 221 | values.push(value) 222 | } 223 | 224 | // Join all our found static values together. 225 | const staticResult = values.join('') 226 | // The path starts with slash, so trim the leading variable and replace with a dot 227 | if (staticResult.startsWith('/')) return `.${staticResult}` 228 | // The path doesn't start with a slash, so it's invalid 229 | // PERF: can we fail earlier than this? 230 | return null 231 | } 232 | -------------------------------------------------------------------------------- /server/src/util/tree-sitter.ts: -------------------------------------------------------------------------------- 1 | import * as LSP from 'vscode-languageserver/node' 2 | import { SyntaxNode } from 'web-tree-sitter' 3 | 4 | /** 5 | * Recursively iterate over all nodes in a tree. 6 | * 7 | * @param node The node to start iterating from 8 | * @param callback The callback to call for each node. Return false to stop following children. 9 | */ 10 | export function forEach(node: SyntaxNode, callback: (n: SyntaxNode) => void | boolean) { 11 | const followChildren = callback(node) !== false 12 | if (followChildren && node.children.length) { 13 | node.children.forEach((n) => forEach(n, callback)) 14 | } 15 | } 16 | 17 | export function range(n: SyntaxNode): LSP.Range { 18 | return LSP.Range.create( 19 | n.startPosition.row, 20 | n.startPosition.column, 21 | n.endPosition.row, 22 | n.endPosition.column, 23 | ) 24 | } 25 | 26 | export function isDefinition(n: SyntaxNode): boolean { 27 | switch (n.type) { 28 | case 'variable_assignment': 29 | case 'function_definition': 30 | return true 31 | default: 32 | return false 33 | } 34 | } 35 | 36 | export function isReference(n: SyntaxNode): boolean { 37 | switch (n.type) { 38 | case 'variable_name': 39 | case 'command_name': 40 | return true 41 | default: 42 | return false 43 | } 44 | } 45 | 46 | export function isVariableInReadCommand(n: SyntaxNode): boolean { 47 | if ( 48 | n.type === 'word' && 49 | n.parent?.type === 'command' && 50 | n.parent.firstChild?.text === 'read' && 51 | !n.text.startsWith('-') && 52 | !/^-.*[dinNptu]$/.test(n.previousSibling?.text ?? '') 53 | ) { 54 | return true 55 | } 56 | 57 | return false 58 | } 59 | 60 | export function isExpansion(n: SyntaxNode): boolean { 61 | switch (n.type) { 62 | case 'expansion': 63 | case 'simple_expansion': 64 | return true 65 | default: 66 | return false 67 | } 68 | } 69 | 70 | export function findParent( 71 | start: SyntaxNode, 72 | predicate: (n: SyntaxNode) => boolean, 73 | ): SyntaxNode | null { 74 | let node = start.parent 75 | while (node !== null) { 76 | if (predicate(node)) { 77 | return node 78 | } 79 | node = node.parent 80 | } 81 | return null 82 | } 83 | 84 | export function findParentOfType(start: SyntaxNode, type: string | string[]) { 85 | if (typeof type === 'string') { 86 | return findParent(start, (n) => n.type === type) 87 | } 88 | 89 | return findParent(start, (n) => type.includes(n.type)) 90 | } 91 | 92 | /** 93 | * Resolves the full string value of a node 94 | * Returns null if the value can't be statically determined (ie, it contains a variable or command substition). 95 | * Supports: word, string, raw_string, and concatenation 96 | */ 97 | export function resolveStaticString(node: SyntaxNode): string | null { 98 | if (node.type === 'concatenation') { 99 | const values = [] 100 | for (const child of node.namedChildren) { 101 | const value = resolveStaticString(child) 102 | if (value === null) return null 103 | values.push(value) 104 | } 105 | return values.join('') 106 | } 107 | if (node.type === 'word') return node.text 108 | if (node.type === 'string' || node.type === 'raw_string') { 109 | if (node.namedChildCount === 0) return node.text.slice(1, -1) 110 | const children = node.namedChildren 111 | if (children.length === 1 && children[0].type === 'string_content') 112 | return children[0].text 113 | return null 114 | } 115 | return null 116 | } 117 | -------------------------------------------------------------------------------- /server/tree-sitter-bash.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash-lsp/bash-language-server/71218fc93c597173b93534815207fb40acef1cb8/server/tree-sitter-bash.wasm -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "out", 5 | "rootDir": "src", 6 | 7 | "lib": [ 8 | "dom", // Required by @types/turndown 9 | "es2016" 10 | ] 11 | }, 12 | "include": ["src"], 13 | "exclude": ["node_modules", "../testing", "src/**/__tests__"] 14 | } 15 | -------------------------------------------------------------------------------- /testing/executables/iam-executable: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # I have my executable bit set. 3 | echo "Hello" 4 | -------------------------------------------------------------------------------- /testing/executables/iam-not-executable: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # I don't have my executable bit set. 3 | echo "Hello" 4 | -------------------------------------------------------------------------------- /testing/executables/sub-folder/iam-executable-in-sub-folder: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # I have my executable bit set. 3 | echo "Hello" 4 | -------------------------------------------------------------------------------- /testing/fixtures.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | import { TextDocument } from 'vscode-languageserver-textdocument' 4 | 5 | export const FIXTURE_FOLDER = path.join(__dirname, './fixtures/') 6 | 7 | function getDocument(uri: string) { 8 | return TextDocument.create( 9 | uri, 10 | 'shellscript', 11 | 0, 12 | fs.readFileSync(uri.replace('file://', ''), 'utf8'), 13 | ) 14 | } 15 | 16 | type FIXTURE_KEY = keyof typeof FIXTURE_URI 17 | 18 | export const FIXTURE_URI = { 19 | COMMENT_DOC: `file://${path.join(FIXTURE_FOLDER, 'comment-doc-on-hover.sh')}`, 20 | CRASH: `file://${path.join(FIXTURE_FOLDER, 'crash.zsh')}`, 21 | INSTALL: `file://${path.join(FIXTURE_FOLDER, 'install.sh')}`, 22 | ISSUE101: `file://${path.join(FIXTURE_FOLDER, 'issue101.sh')}`, 23 | ISSUE206: `file://${path.join(FIXTURE_FOLDER, 'issue206.sh')}`, 24 | MISSING_EXTENSION: `file://${path.join(FIXTURE_FOLDER, 'extension')}`, 25 | EXTENSION_INC: `file://${path.join(FIXTURE_FOLDER, 'extension.inc')}`, 26 | MISSING_NODE: `file://${path.join(FIXTURE_FOLDER, 'missing-node.sh')}`, 27 | OPTIONS: `file://${path.join(FIXTURE_FOLDER, 'options.sh')}`, 28 | OVERRIDE_SYMBOL: `file://${path.join(FIXTURE_FOLDER, 'override-executable-symbol.sh')}`, 29 | PARSE_PROBLEMS: `file://${path.join(FIXTURE_FOLDER, 'parse-problems.sh')}`, 30 | SCOPE: `file://${path.join(FIXTURE_FOLDER, 'scope.sh')}`, 31 | SHELLCHECK_SOURCE: `file://${path.join(FIXTURE_FOLDER, 'shellcheck', 'source.sh')}`, 32 | SHELLCHECK_SHELL_DIRECTIVE: `file://${path.join( 33 | FIXTURE_FOLDER, 34 | 'shellcheck', 35 | 'shell-directive.bash', 36 | )}`, 37 | SHFMT: `file://${path.join(FIXTURE_FOLDER, 'shfmt.sh')}`, 38 | SOURCING: `file://${path.join(FIXTURE_FOLDER, 'sourcing.sh')}`, 39 | SOURCING2: `file://${path.join(FIXTURE_FOLDER, 'sourcing2.sh')}`, 40 | RENAMING: `file://${path.join(FIXTURE_FOLDER, 'renaming.sh')}`, 41 | RENAMING_READ: `file://${path.join(FIXTURE_FOLDER, 'renaming-read.sh')}`, 42 | } 43 | 44 | export const FIXTURE_DOCUMENT: Record = ( 45 | Object.keys(FIXTURE_URI) as Array 46 | ).reduce((acc, cur: FIXTURE_KEY) => { 47 | acc[cur] = getDocument(FIXTURE_URI[cur]) 48 | return acc 49 | }, {} as any) 50 | 51 | export const REPO_ROOT_FOLDER = path.resolve(path.join(FIXTURE_FOLDER, '../..')) 52 | 53 | export function updateSnapshotUris< 54 | T extends Record | Array | null | undefined, 55 | >(data: T): T { 56 | if (data != null) { 57 | if (Array.isArray(data)) { 58 | data.forEach((el) => updateSnapshotUris(el)) 59 | return data 60 | } 61 | 62 | if (typeof data === 'object') { 63 | if (data.changes) { 64 | for (const key in data.changes) { 65 | data.changes[key.replace(REPO_ROOT_FOLDER, '__REPO_ROOT_FOLDER__')] = 66 | data.changes[key] 67 | delete data.changes[key] 68 | } 69 | 70 | return data 71 | } 72 | 73 | if (data.uri) { 74 | data.uri = data.uri.replace(REPO_ROOT_FOLDER, '__REPO_ROOT_FOLDER__') 75 | } 76 | Object.values(data).forEach((child) => { 77 | if (Array.isArray(child)) { 78 | child.forEach((el) => updateSnapshotUris(el)) 79 | } else if (typeof child === 'object' && child != null) { 80 | updateSnapshotUris(child) 81 | } 82 | }) 83 | } 84 | } 85 | 86 | return data 87 | } 88 | -------------------------------------------------------------------------------- /testing/fixtures/basic-zsh.zsh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zsh 2 | # Path to your oh-my-zsh installation. 3 | export ZSH=~/.oh-my-zsh 4 | 5 | # Uncomment the following line if you want to disable marking untracked files 6 | # under VCS as dirty. This makes repository status check for large repositories 7 | # much, much faster. 8 | DISABLE_UNTRACKED_FILES_DIRTY="true" 9 | 10 | fpath=(/usr/local/share/zsh-completions $fpath) 11 | 12 | export CLICOLOR=1 13 | 14 | echo $DISABLE_UNTRACKED_FILES_DIRTY 15 | -------------------------------------------------------------------------------- /testing/fixtures/broken-symlink.sh: -------------------------------------------------------------------------------- 1 | x.sh -------------------------------------------------------------------------------- /testing/fixtures/comment-doc-on-hover.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | # this is a comment 5 | # describing the function 6 | # hello_world 7 | # this function takes two arguments 8 | hello_world() { 9 | echo "hello world to: $1 and $2" 10 | } 11 | 12 | 13 | 14 | # if the user hovers above the below hello_world invocation 15 | # they should see the comment doc string in a tooltip 16 | # containing the lines 4 - 7 above 17 | 18 | hello_world "bob" "sally" 19 | 20 | 21 | 22 | # doc for func_one 23 | func_one() { 24 | echo "func_one" 25 | } 26 | 27 | # doc for func_two 28 | # has two lines 29 | func_two() { 30 | echo "func_two" 31 | } 32 | 33 | 34 | # this is not included 35 | 36 | # doc for func_three 37 | func_three() { 38 | echo "func_three" 39 | } 40 | 41 | 42 | # works for variables 43 | my_var="pizza" 44 | 45 | 46 | my_other_var="no comments above me :(" 47 | 48 | 49 | # this is also included 50 | # 51 | # doc for func_four 52 | func_four() { 53 | echo "func_four" 54 | } 55 | 56 | echo $unknown 57 | -------------------------------------------------------------------------------- /testing/fixtures/crash.zsh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | # https://github.com/bash-lsp/bash-language-server/issues/880 3 | if [[ -r "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh" ]]; then 4 | source "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh" 5 | fi 6 | 7 | -------------------------------------------------------------------------------- /testing/fixtures/extension: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | . ./extension.inc 4 | 5 | echo "It works, but is not parsed initially" 6 | 7 | # A little variable 8 | export XXX=1 9 | -------------------------------------------------------------------------------- /testing/fixtures/extension.inc: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | source ./issue101.sh 4 | 5 | RED=`tput setaf 1` 6 | GREEN=`tput setaf 2` 7 | local BLUE=`tput setaf 4` # local at the root is still exposed to the global scope 8 | export BOLD=`tput bold` 9 | 10 | { # non colors in a group 11 | RESET=`tput sgr0` 12 | } 13 | 14 | extensionFunc() { 15 | LOCAL_VARIABLE='local' 16 | 17 | innerExtensionFunc() { 18 | echo $LOCAL_VARIABLE 19 | } 20 | } 21 | 22 | if [ "${ENV}" = "prod" ]; then 23 | RED="" # Ignored as we do not flowtrace. 24 | fi 25 | 26 | : "${FILE_PATH:="/default/file"}" 27 | 28 | printf 'Printing %s\n' "$FILE_PATH" 29 | -------------------------------------------------------------------------------- /testing/fixtures/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Origin: https://github.com/tree-sitter/tree-sitter-bash/blob/master/examples/install.sh 3 | # A word about this shell script: 4 | # 5 | # It must work everywhere, including on systems that lack 6 | # a /bin/bash, map 'sh' to ksh, ksh97, bash, ash, or zsh, 7 | # and potentially have either a posix shell or bourne 8 | # shell living at /bin/sh. 9 | # 10 | # See this helpful document on writing portable shell scripts: 11 | # http://www.gnu.org/s/hello/manual/autoconf/Portable-Shell.html 12 | # 13 | # The only shell it won't ever work on is cmd.exe. 14 | 15 | if [ "x$0" = "xsh" ]; then 16 | # run as curl | sh 17 | # on some systems, you can just do cat>npm-install.sh 18 | # which is a bit cuter. But on others, &1 is already closed, 19 | # so catting to another script file won't do anything. 20 | # Follow Location: headers, and fail on errors 21 | curl -f -L -s https://www.npmjs.org/install.sh > npm-install-$$.sh 22 | ret=$? 23 | if [ $ret -eq 0 ]; then 24 | (exit 0) 25 | else 26 | rm npm-install-$$.sh 27 | echo "Failed to download script" >&2 28 | exit $ret 29 | fi 30 | sh npm-install-$$.sh 31 | ret=$? 32 | rm npm-install-$$.sh 33 | exit $ret 34 | fi 35 | 36 | # See what "npm_config_*" things there are in the env, 37 | # and make them permanent. 38 | # If this fails, it's not such a big deal. 39 | configures="`env | grep 'npm_config_' | sed -e 's|^npm_config_||g'`" 40 | 41 | npm_config_loglevel="error" 42 | if [ "x$npm_debug" = "x" ]; then 43 | (exit 0) 44 | else 45 | echo "Running in debug mode." 46 | echo "Note that this requires bash or zsh." 47 | set -o xtrace 48 | set -o pipefail 49 | npm_config_loglevel="verbose" 50 | fi 51 | export npm_config_loglevel 52 | 53 | # make sure that node exists 54 | node=`which node 2>&1` 55 | ret=$? 56 | if [ $ret -eq 0 ] && [ -x "$node" ]; then 57 | (exit 0) 58 | else 59 | echo "npm cannot be installed without node.js." >&2 60 | echo "Install node first, and then try again." >&2 61 | echo "" >&2 62 | echo "Maybe node is installed, but not in the PATH?" >&2 63 | echo "Note that running as sudo can change envs." >&2 64 | echo "" 65 | echo "PATH=$PATH" >&2 66 | exit $ret 67 | fi 68 | 69 | # set the temp dir 70 | TMP="${TMPDIR}" 71 | if [ "x$TMP" = "x" ]; then 72 | TMP="/tmp" 73 | fi 74 | TMP="${TMP}/npm.$$" 75 | rm -rf "$TMP" || true 76 | mkdir "$TMP" 77 | if [ $? -ne 0 ]; then 78 | echo "failed to mkdir $TMP" >&2 79 | exit 1 80 | fi 81 | 82 | BACK="$PWD" 83 | 84 | ret=0 85 | tar="${TAR}" 86 | if [ -z "$tar" ]; then 87 | tar="${npm_config_tar}" 88 | fi 89 | if [ -z "$tar" ]; then 90 | tar=`which tar 2>&1` 91 | ret=$? 92 | fi 93 | 94 | if [ $ret -eq 0 ] && [ -x "$tar" ]; then 95 | echo "tar=$tar" 96 | echo "version:" 97 | $tar --version 98 | ret=$? 99 | fi 100 | 101 | if [ $ret -eq 0 ]; then 102 | (exit 0) 103 | else 104 | echo "No suitable tar program found." 105 | exit 1 106 | fi 107 | 108 | 109 | 110 | # Try to find a suitable make 111 | # If the MAKE environment var is set, use that. 112 | # otherwise, try to find gmake, and then make. 113 | # If no make is found, then just execute the necessary commands. 114 | 115 | # XXX For some reason, make is building all the docs every time. This 116 | # is an annoying source of bugs. Figure out why this happens. 117 | MAKE=NOMAKE 118 | 119 | if [ "x$MAKE" = "x" ]; then 120 | make=`which gmake 2>&1` 121 | if [ $? -eq 0 ] && [ -x "$make" ]; then 122 | (exit 0) 123 | else 124 | make=`which make 2>&1` 125 | if [ $? -eq 0 ] && [ -x "$make" ]; then 126 | (exit 0) 127 | else 128 | make=NOMAKE 129 | fi 130 | fi 131 | else 132 | make="$MAKE" 133 | fi 134 | 135 | if [ -x "$make" ]; then 136 | (exit 0) 137 | else 138 | # echo "Installing without make. This may fail." >&2 139 | make=NOMAKE 140 | fi 141 | 142 | # If there's no bash, then don't even try to clean 143 | if [ -x "/bin/bash" ]; then 144 | (exit 0) 145 | else 146 | clean="no" 147 | fi 148 | 149 | node_version=`"$node" --version 2>&1` 150 | ret=$? 151 | if [ $ret -ne 0 ]; then 152 | echo "You need node to run this program." >&2 153 | echo "node --version reports: $node_version" >&2 154 | echo "with exit code = $ret" >&2 155 | echo "Please install node before continuing." >&2 156 | exit $ret 157 | fi 158 | 159 | t="${npm_install}" 160 | if [ -z "$t" ]; then 161 | # switch based on node version. 162 | # note that we can only use strict sh-compatible patterns here. 163 | case $node_version in 164 | 0.[01234567].* | v0.[01234567].*) 165 | echo "You are using an outdated and unsupported version of" >&2 166 | echo "node ($node_version). Please update node and try again." >&2 167 | exit 99 168 | ;; 169 | *) 170 | echo "install npm@latest" 171 | t="latest" 172 | ;; 173 | esac 174 | fi 175 | 176 | # need to echo "" after, because Posix sed doesn't treat EOF 177 | # as an implied end of line. 178 | url=`(curl -SsL https://registry.npmjs.org/npm/$t; echo "") \ 179 | | sed -e 's/^.*tarball":"//' \ 180 | | sed -e 's/".*$//'` 181 | 182 | ret=$? 183 | if [ "x$url" = "x" ]; then 184 | ret=125 185 | # try without the -e arg to sed. 186 | url=`(curl -SsL https://registry.npmjs.org/npm/$t; echo "") \ 187 | | sed 's/^.*tarball":"//' \ 188 | | sed 's/".*$//'` 189 | ret=$? 190 | if [ "x$url" = "x" ]; then 191 | ret=125 192 | fi 193 | fi 194 | if [ $ret -ne 0 ]; then 195 | echo "Failed to get tarball url for npm/$t" >&2 196 | exit $ret 197 | fi 198 | 199 | 200 | echo "fetching: $url" >&2 201 | 202 | cd "$TMP" \ 203 | && curl -SsL "$url" \ 204 | | $tar -xzf - \ 205 | && cd "$TMP"/* \ 206 | && (ver=`"$node" bin/read-package-json.js package.json version` 207 | isnpm10=0 208 | if [ $ret -eq 0 ]; then 209 | if [ -d node_modules ]; then 210 | if "$node" node_modules/semver/bin/semver -v "$ver" -r "1" 211 | then 212 | isnpm10=1 213 | fi 214 | else 215 | if "$node" bin/semver -v "$ver" -r ">=1.0"; then 216 | isnpm10=1 217 | fi 218 | fi 219 | fi 220 | 221 | ret=0 222 | if [ $isnpm10 -eq 1 ] && [ -f "scripts/clean-old.sh" ]; then 223 | if [ "x$skipclean" = "x" ]; then 224 | (exit 0) 225 | else 226 | clean=no 227 | fi 228 | if [ "x$clean" = "xno" ] \ 229 | || [ "x$clean" = "xn" ]; then 230 | echo "Skipping 0.x cruft clean" >&2 231 | ret=0 232 | elif [ "x$clean" = "xy" ] || [ "x$clean" = "xyes" ]; then 233 | NODE="$node" /bin/bash "scripts/clean-old.sh" "-y" 234 | ret=$? 235 | else 236 | NODE="$node" /bin/bash "scripts/clean-old.sh" &2 243 | exit $ret 244 | fi) \ 245 | && (if [ "x$configures" = "x" ]; then 246 | (exit 0) 247 | else 248 | echo "./configure $configures" 249 | echo "$configures" > npmrc 250 | fi) \ 251 | && (if [ "$make" = "NOMAKE" ]; then 252 | (exit 0) 253 | elif "$make" uninstall install; then 254 | (exit 0) 255 | else 256 | make="NOMAKE" 257 | fi 258 | if [ "$make" = "NOMAKE" ]; then 259 | "$node" cli.js rm npm -gf 260 | "$node" cli.js install -gf 261 | fi) \ 262 | && cd "$BACK" \ 263 | && rm -rf "$TMP" \ 264 | && echo "It worked" 265 | 266 | ret=$? 267 | if [ $ret -ne 0 ]; then 268 | echo "It failed" >&2 269 | fi 270 | exit $ret 271 | 272 | iverilog -i wave.vpp *{}.v 273 | 274 | : "${FILE_PATH_EXPANSION:="/default/file"}" 275 | 276 | printf 'Printing %s\n' "$FILE_PATH_EXPANSION" 277 | -------------------------------------------------------------------------------- /testing/fixtures/issue101.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Helper function to add a user 4 | add_a_user() 5 | { 6 | USER=$1 7 | PASSWORD=$2 8 | shift; shift; 9 | # Having shifted twice, the rest is now comments ... 10 | COMMENTS=$@ 11 | echo "Adding user $USER ..." 12 | echo useradd -c "$COMMENTS" $USER 13 | echo passwd $USER $PASSWORD 14 | echo "Added user $USER ($COMMENTS) with pass $PASSWORD" 15 | } 16 | 17 | ### 18 | # Main body of script starts here 19 | ### 20 | echo "Start of script..." 21 | add_a_user bob letmein Bob Holness the presenter 22 | add_a_user fred badpassword Fred Durst the singer 23 | add_a_user bilko worsepassword Sgt. Bilko the role model 24 | echo "End of script..." 25 | -------------------------------------------------------------------------------- /testing/fixtures/issue206.sh: -------------------------------------------------------------------------------- 1 | readonly FOO=$1 2 | readonly BAR=$UNDEFINED/$FOO 3 | -------------------------------------------------------------------------------- /testing/fixtures/just-a-folder.sh/README.md: -------------------------------------------------------------------------------- 1 | ### Nothing here... 2 | -------------------------------------------------------------------------------- /testing/fixtures/missing-node.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # set -x 3 | set -e 4 | 5 | PATH_INPUT=src/in.js 6 | PATH_OUTPUT=src/out.js 7 | 8 | if [[ $PATH_INPUT -nt $PATH_OUTPUT ]]; then 9 | babel --compact false ${PATH_INPUT} > ${PATH_OUTPUT} 10 | f 11 | 12 | echo "test" 13 | -------------------------------------------------------------------------------- /testing/fixtures/not-a-shell-script.sh: -------------------------------------------------------------------------------- 1 | if this is not a shell script, then it should not be parsed 2 | 3 | echo "Or is it?" 4 | 5 | -------------------------------------------------------------------------------- /testing/fixtures/not-a-shell-script2.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2.7 2 | # set -x 3 | 4 | def func(): 5 | print 'hello world' 6 | -------------------------------------------------------------------------------- /testing/fixtures/options.sh: -------------------------------------------------------------------------------- 1 | grep - 2 | grep -- 3 | grep --line- 4 | -------------------------------------------------------------------------------- /testing/fixtures/override-executable-symbol.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ls -la 4 | 5 | # override documentation for `ls` symbol 6 | ls() { 7 | echo "Overridden" 8 | } 9 | 10 | ls 11 | -------------------------------------------------------------------------------- /testing/fixtures/parse-problems.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # set -x 3 | set -e 4 | 5 | PATH_INPUT=src/in.js 6 | PATH_OUTPUT=src/out.js 7 | 8 | if [[ $PATH_INPUT -nt $PATH_OUTPUT ]]; then 9 | babel --compact false ${PATH_INPUT} > ${PATH_OUTPUT} 10 | > 11 | -------------------------------------------------------------------------------- /testing/fixtures/renaming-read.sh: -------------------------------------------------------------------------------- 1 | # Varying cases and flag usage 2 | 3 | read readvar readvar readvar 4 | read -r readvar readvar <<< "some output" 5 | echo readvar 6 | 7 | read -n1 readvar 8 | read -t 10 readvar 9 | read -rd readvar -p readvar readvar 10 | echo $readvar 11 | 12 | read -p readvar -a readvar -er 13 | read -sp "Prompt: " -ra readvar 14 | echo "${readvar[2]}" 15 | 16 | read readvar -rp readvar readvar 17 | read -r readvar -p readvar readvar 18 | read -p readvar -ir readvar readvar 19 | 20 | # While loop 21 | 22 | while read -r readloop; do 23 | printf readloop 24 | echo "$readloop" 25 | done < somefile.txt 26 | 27 | # Different scopes 28 | 29 | read readscope 30 | readfunc() { 31 | read readscope 32 | echo $readscope 33 | 34 | local readscope 35 | read readscope 36 | echo $readscope 37 | } 38 | ( 39 | echo $readscope 40 | 41 | read readscope 42 | echo $readscope 43 | ) 44 | echo $readscope 45 | -------------------------------------------------------------------------------- /testing/fixtures/renaming.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Non-renamable variables 4 | 5 | echo "$_" 6 | _="reassigned" 7 | echo "$1" 8 | 1abc="1abc" 9 | 10 | # Variable and function with the same name 11 | 12 | variable_or_function="false" 13 | function variable_or_function { 14 | echo "$variable_or_function" 15 | 16 | if [[ "$variable_or_function" == "false" ]]; then 17 | variable_or_function="true" 18 | variable_or_function 19 | fi 20 | } 21 | 22 | # Undeclared symbols 23 | 24 | echo "$HOME" 25 | ls "$HOME" 26 | 27 | # Globally scoped declarations 28 | 29 | somefunc() { echo "true"; } 30 | somevar="$(ls somedir)" 31 | : "${othervar:="$(ls otherdir)"}" 32 | 33 | if [ "$(somefunc)" = "true" ]; then 34 | echo "$somevar" 35 | echo "$othervar" 36 | fi 37 | 38 | # Function-scoped declarations 39 | 40 | function regularFunc() { 41 | echo "${somevar:="fallback"}" 42 | somevar="reassigned" 43 | 44 | local a somevar="${somevar:0:8} 45 | $somevar" b 46 | 47 | somefunc() { 48 | somevar="reassigned again" 49 | } 50 | somefunc 51 | 52 | ( 53 | if [ -n "$somevar" ]; then 54 | declare a b somevar 55 | echo $somevar 56 | fi 57 | ) 58 | 59 | echo "${somevar}" 60 | } 61 | 62 | # Subshell-scoped declarations 63 | 64 | subshellFunc() ( 65 | echo "${somevar:="fallback"}" 66 | somevar="${somevar:0:8} 67 | $somevar" 68 | 69 | local somevar 70 | 71 | ( 72 | somevar="reassigned" 73 | echo "$somevar" 74 | ) 75 | 76 | function somefunc { 77 | if [ -z "$somevar" ]; then 78 | typeset somevar="reassigned" a b 79 | echo "${somevar}" 80 | fi 81 | } 82 | somefunc 83 | 84 | echo $somevar 85 | ) 86 | 87 | # Workspace-wide symbols 88 | 89 | source ./sourcing.sh 90 | 91 | RED="#FF0000" 92 | echo $RED 93 | 94 | tagRelease() { echo "reimplemented"; } 95 | tagRelease 96 | 97 | # From install.sh 98 | npm_config_loglevel="info" 99 | echo $npm_config_loglevel 100 | 101 | # From scope.sh 102 | f() { echo "reimplemented"; } 103 | f 104 | 105 | # tree-sitter-bash's parsing limitations with let and arithmetic expressions 106 | 107 | for i in 1 2 3; do 108 | echo "$i" 109 | done 110 | 111 | let i=0 112 | for (( ; i < 10; i++)); do 113 | echo "$((2 * i))" 114 | done 115 | 116 | # Complex nesting affects self-assignment handling 117 | 118 | f1() { 119 | local var="var" 120 | 121 | f2() ( 122 | var=$var 123 | 124 | f3() { 125 | declare var="$var" 126 | } 127 | ) 128 | } 129 | 130 | # Complex scoping affects symbol visibility 131 | 132 | callerFunc() ( 133 | localFunc() { 134 | echo "Hello world" 135 | } 136 | calleeFunc 137 | ) 138 | calleeFunc() { 139 | localFunc 140 | } 141 | callerFunc 142 | 143 | # Pipeline subshell scope is not recognized 144 | 145 | { pipelinevar="value"; } | { echo $pipelinevar; } 146 | 147 | # Sourcing after affects symbols before 148 | 149 | echo "$FOO" 150 | . ./issue206.sh 151 | 152 | # Sourcing within functions and subshells affects symbols outside 153 | 154 | sourcingFunc() { source ./comment-doc-on-hover.sh; } 155 | hello_world "john" "jack" 156 | 157 | (. ./missing-node.sh) 158 | echo "$PATH_INPUT" 159 | -------------------------------------------------------------------------------- /testing/fixtures/scope.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | X="Horse" 4 | 5 | X="Mouse" 6 | 7 | # some function 8 | f() ( 9 | local X="Dog" 10 | GLOBAL_1="Global 1" 11 | 12 | g() { 13 | local X="Cat" 14 | GLOBAL_1="Global 1" 15 | GLOBAL_2="Global 1" 16 | echo "${X}" 17 | 18 | # another function function 19 | f() { 20 | local X="Bird" 21 | echo "${X}" 22 | } 23 | 24 | f 25 | } 26 | 27 | g 28 | 29 | echo "${GLOBAL_1}" 30 | echo "${X}" 31 | ) 32 | 33 | echo "${X}" 34 | f 35 | 36 | echo "${GLOBAL_2}" 37 | 38 | for i in 1 2 3 4 5 39 | do 40 | echo "$GLOBAL_1 $i" 41 | done 42 | 43 | echo "$npm_config_loglevel" # this is an undefined variable, but defined in install.sh 44 | -------------------------------------------------------------------------------- /testing/fixtures/shellcheck/shell-directive.bash: -------------------------------------------------------------------------------- 1 | #!/bin/env ksh 2 | # this shebang must be overriden by shell directive 3 | # this line must be ignored 4 | 5 | # shellcheck disable=SC1072 shell=sh enable=require-variable-braces 6 | 7 | 8 | if [[ -n "$TERM" ]]; then 9 | echo "$TERM" 10 | fi 11 | 12 | # shellcheck shell=dash 13 | -------------------------------------------------------------------------------- /testing/fixtures/shellcheck/source.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # shellcheck source=shellcheck/sourced.sh 4 | source ./sourced.sh 5 | 6 | echo "$foo" 7 | -------------------------------------------------------------------------------- /testing/fixtures/shellcheck/sourced.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | export foo=1 3 | -------------------------------------------------------------------------------- /testing/fixtures/shfmt-editorconfig/no-shfmt-properties/.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | indent_size = 3 4 | -------------------------------------------------------------------------------- /testing/fixtures/shfmt-editorconfig/shfmt-properties-false/.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | indent_size = 3 4 | 5 | switch_case_indent = false 6 | space_redirects = false 7 | -------------------------------------------------------------------------------- /testing/fixtures/shfmt-editorconfig/shfmt-properties/.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | indent_size = 3 4 | 5 | switch_case_indent = true 6 | space_redirects = true 7 | shell_variant = 'mksh' 8 | -------------------------------------------------------------------------------- /testing/fixtures/shfmt.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ueo pipefail 3 | 4 | if [ -z "$arg" ]; then 5 | echo indent 6 | fi 7 | 8 | echo binary&& 9 | echo next line 10 | 11 | case "$arg" in 12 | a) 13 | echo case indent 14 | ;; 15 | esac 16 | 17 | echo one two three 18 | echo four five six 19 | echo seven eight nine 20 | 21 | [[ "$simplify" == "simplify" ]] 22 | 23 | echo space redirects> /dev/null 24 | 25 | function next(){ 26 | echo line 27 | } 28 | -------------------------------------------------------------------------------- /testing/fixtures/sourcing.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | source ./extension.inc 4 | 5 | echo $RED 'Hello in red!' 6 | 7 | echo $BLU 8 | 9 | add_a_us 10 | 11 | BOLD=`tput bold` # redefined 12 | 13 | echo $BOL 14 | 15 | echo "$" 16 | 17 | source ./scripts/tag-release.inc 18 | 19 | tagRelease '1.0.0' 20 | 21 | loadlib () { 22 | source "$1.sh" 23 | } 24 | 25 | loadlib "issue206" 26 | 27 | source $SCRIPT_DIR/tag-release.inc with arguments 28 | -------------------------------------------------------------------------------- /testing/fixtures/sourcing2.sh: -------------------------------------------------------------------------------- 1 | . ./extension # notice no file extension meaning it isn't found by the background analysis 2 | 3 | echo "It resolves ${XXX} in the sourced file" 4 | -------------------------------------------------------------------------------- /testing/mocks.ts: -------------------------------------------------------------------------------- 1 | import * as LSP from 'vscode-languageserver/node' 2 | 3 | export function getMockConnection(): jest.Mocked { 4 | const console: any = { 5 | error: jest.fn(), 6 | warn: jest.fn(), 7 | info: jest.fn(), 8 | log: jest.fn(), 9 | } 10 | 11 | return { 12 | client: { 13 | connection: {} as any, 14 | register: jest.fn(), 15 | initialize: jest.fn(), 16 | fillServerCapabilities: jest.fn(), 17 | }, 18 | console, 19 | dispose: jest.fn(), 20 | languages: {} as any, 21 | listen: jest.fn(), 22 | notebooks: {} as any, 23 | onCodeAction: jest.fn(), 24 | onCodeActionResolve: jest.fn(), 25 | onCodeLens: jest.fn(), 26 | onCodeLensResolve: jest.fn(), 27 | onColorPresentation: jest.fn(), 28 | onCompletion: jest.fn(), 29 | onCompletionResolve: jest.fn(), 30 | onDeclaration: jest.fn(), 31 | onDefinition: jest.fn(), 32 | onDidChangeConfiguration: jest.fn(), 33 | onDidChangeTextDocument: jest.fn(), 34 | onDidChangeWatchedFiles: jest.fn(), 35 | onDidCloseTextDocument: jest.fn(), 36 | onDidOpenTextDocument: jest.fn(), 37 | onDidSaveTextDocument: jest.fn(), 38 | onDocumentColor: jest.fn(), 39 | onDocumentFormatting: jest.fn(), 40 | onDocumentHighlight: jest.fn(), 41 | onDocumentLinkResolve: jest.fn(), 42 | onDocumentLinks: jest.fn(), 43 | onDocumentOnTypeFormatting: jest.fn(), 44 | onDocumentRangeFormatting: jest.fn(), 45 | onDocumentSymbol: jest.fn(), 46 | onExecuteCommand: jest.fn(), 47 | onExit: jest.fn(), 48 | onFoldingRanges: jest.fn(), 49 | onHover: jest.fn(), 50 | onImplementation: jest.fn(), 51 | onInitialize: jest.fn(), 52 | onInitialized: jest.fn(), 53 | onNotification: jest.fn(), 54 | onPrepareRename: jest.fn(), 55 | onProgress: jest.fn(), 56 | onReferences: jest.fn(), 57 | onRenameRequest: jest.fn(), 58 | onRequest: jest.fn(), 59 | onSelectionRanges: jest.fn(), 60 | onShutdown: jest.fn(), 61 | onSignatureHelp: jest.fn(), 62 | onTypeDefinition: jest.fn(), 63 | onWillSaveTextDocument: jest.fn(), 64 | onWillSaveTextDocumentWaitUntil: jest.fn(), 65 | onWorkspaceSymbol: jest.fn(), 66 | onWorkspaceSymbolResolve: jest.fn(), 67 | sendDiagnostics: jest.fn(), 68 | sendNotification: jest.fn(), 69 | sendProgress: jest.fn(), 70 | sendRequest: jest.fn(), 71 | telemetry: {} as any, 72 | tracer: {} as any, 73 | window: { 74 | attachWorkDoneProgress: jest.fn(), 75 | connection: {} as any, 76 | createWorkDoneProgress: jest.fn(), 77 | fillServerCapabilities: jest.fn(), 78 | initialize: jest.fn(), 79 | showDocument: jest.fn(), 80 | showErrorMessage: jest.fn(), 81 | showInformationMessage: jest.fn(), 82 | showWarningMessage: jest.fn(), 83 | }, 84 | workspace: { 85 | applyEdit: jest.fn(), 86 | connection: {} as any, 87 | fillServerCapabilities: jest.fn(), 88 | getConfiguration: jest.fn(), 89 | getWorkspaceFolders: jest.fn(), 90 | initialize: jest.fn(), 91 | onDidChangeWorkspaceFolders: jest.fn(), 92 | onDidCreateFiles: jest.fn(), 93 | onDidDeleteFiles: jest.fn(), 94 | onDidRenameFiles: jest.fn(), 95 | onWillCreateFiles: jest.fn(), 96 | onWillDeleteFiles: jest.fn(), 97 | onWillRenameFiles: jest.fn(), 98 | }, 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "node_modules" 5 | ], 6 | "include": [ 7 | "*.*", 8 | "server", 9 | "testing", 10 | "vscode-client" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": true, 4 | "noImplicitReturns": true, 5 | "strict": true, 6 | "target": "es6", 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "declaration": true, 10 | "lib": [ 11 | "es2016" 12 | ], 13 | "rootDir": ".", 14 | "sourceMap": true, 15 | "skipLibCheck": true, 16 | }, 17 | "include": [ 18 | "testing" 19 | ], 20 | "exclude": [ 21 | "node_modules", 22 | "**/__tests__/*", 23 | "vscode-client/out", 24 | "server/out", 25 | "server/setupJest.ts", 26 | "testing" 27 | ], 28 | "references": [ 29 | { "path": "./vscode-client" }, 30 | { "path": "./server" } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /vscode-client/.npmrc: -------------------------------------------------------------------------------- 1 | save-exact = true 2 | # https://github.com/microsoft/vscode-vsce/issues/421 3 | node-linker = hoisted 4 | shamefully-hoist = true 5 | -------------------------------------------------------------------------------- /vscode-client/.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | typings/** 3 | out/test/** 4 | test/** 5 | src/** 6 | **/*.map 7 | .gitignore 8 | tsconfig.json 9 | vsc-extension-quickstart.md 10 | -------------------------------------------------------------------------------- /vscode-client/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Bash IDE 2 | 3 | ## 1.43.0 4 | - Upgrade language server to 5.4.2 (treesitter upgrade). 5 | 6 | ## 1.42.0 7 | - Upgrade language server to 5.4.0 (format document capability). 8 | 9 | ## 1.41.0 10 | - Upgrade language server to 5.2.0 11 | 12 | ## 1.40.0 13 | - Upgrade language server to 5.1.1 (rename capability). 14 | 15 | ## 1.39.0 16 | - Upgrade language server to 5.0.0. 17 | 18 | ## 1.38.0 19 | - Upgrade language server to 4.10.0. 20 | 21 | ## 1.37.0 22 | - Upgrade language server to 4.9.2. 23 | - Fix flags/options insertion issue. 24 | - More snippets. 25 | 26 | ## 1.36.0 27 | - Upgrade language server to 4.8.4. 28 | - Source error diagnostics ("Source command could not be analyzed") is now disabled by default and can be re-enable using the `enableSourceErrorDiagnostics` configuration flag. 29 | 30 | ## 1.35.0 31 | - Upgrade language server to 4.8.2. 32 | - ShellCheck: avoid using the diagnostic tag "deprecated" which renders diagnostics with a strike through 33 | 34 | ## 1.34.0 35 | 36 | - Upgrade language server to 4.8.1. 37 | - Use ShellCheck directives when analyzing source commands 38 | - Support for bash options auto completions when using Brew or when `pkg-config` fails, but bash completions are found in `"${PREFIX:-/usr}/share/bash-completion/bash_completion"` 39 | 40 | 41 | ## 1.33.0 42 | 43 | - Upgrade language server to 4.6.2. 44 | - Remove diagnostics for missing nodes that turns out to be unstable (this was introduced in 1.30.0). 45 | - Support parsing `: "${VARIABLE:="default"}"` as a variable definition. 46 | 47 | ## 1.32.0 48 | 49 | - Upgrade language server to 4.5.5. 50 | - Use sourcing info even if `includeAllWorkspaceSymbols` is true to ensure that files not matching the `globPattern` (and therefore not part of the background analysis) is still resolved based on source commands. 51 | 52 | ## 1.31.0 53 | 54 | - Upgrade language server to 4.5.4. 55 | - Skip running ShellCheck for unsupported zsh files. We will still run it for bash like files without a shebang or without a known file extension. 56 | 57 | ## 1.30.0 58 | 59 | - Upgrade language server to 4.5.3. 60 | - Fix issue where some features would work as expected in case of a syntax issue 61 | - Fixed `onReferences` to respect the `context.includeDeclaration` flag 62 | - Removed unnecessary dependency `urijs` 63 | 64 | ## 1.29.0 65 | 66 | - Upgrade language server to 4.5.1. 67 | - Improved parsing of global declarations. 68 | - Skip completions in the middle of a non word when the following characters is not an empty list or whitespace. 69 | - Remove infrequent and rather useless "Failed to parse" diagnostics and the `highlightParsingErrors` configuration option – the tree sitter parser is actually rather good at error recovery. Note that these messages will now be shown in the log. 70 | 71 | ## 1.28.0 72 | 73 | - Upgrade language server to 4.5.0. 74 | - Include 30 snippets for language constructs (e.g. `if`), builtins (e.g. `test`), expansions (e.g. `[##]`), and external programs (e.g. `sed`). 75 | - Improved source command parsing. 76 | - Includes diagnostics when we fail to analyze source commands. 77 | - Logging is improved and configurable. 78 | 79 | ## 1.27.0 80 | 81 | - FAULTY RELEASE. The server was stuck on version 4.2.5, but new logging configuration was shipped which made the server fail loading the configuration. 82 | 83 | ## 1.26.0 84 | 85 | - Upgrade language server to 4.2.5 fixing a critical performance issue for workspaces with many files. 86 | 87 | ## 1.25.0 88 | 89 | - Upgrade language server to 4.2.4 with a better ShellCheck performance and support for resolving loop variables. 90 | 91 | ## 1.24.0 92 | 93 | - Upgrade language server to 4.2.0. 94 | 95 | ## 1.23.0 96 | 97 | - Upgrade language server to 4.1.2. 98 | 99 | ## 1.22.0 100 | 101 | - Upgrade language server to 4.1.1. 102 | 103 | ## 1.21.0 104 | 105 | - Upgrade language server to 4.1.0 that makes symbols lookup based on sourced files (using non dynamic statements like `source file.sh` or `. ~/file.inc`) instead of including all symbols from the workspace. We now also support jump-to-definition on the file path used in a source command. The new behavior can be disabled by turning on the `includeAllWorkspaceSymbols` configuration option. 106 | 107 | ## 1.20.1 108 | 109 | - Upgrade language server to 4.0.1 that enables ShellCheck code actions (quick fixes), remove duplicated error codes, add URLs and tags, support parsing dialects (sh, bash, dash, ksh) but still fallback to bash, enable configuring ShellCheck arguments using the `shellcheckArguments` configuration parameter and allows for changing settings while the extension is running. 110 | 111 | ## 1.18.0 112 | 113 | - Upgrade language server to 4.0.0-beta.1 that enables a better ShellCheck integration. 114 | 115 | ## 1.17.0 116 | 117 | - Upgrade language server to 3.3.0 that enables faster background analysis and defaults to enabling ShellCheck linting the ShellCheck executable is found. We strongly recommend installing ShellCheck. 118 | 119 | ## 1.16.2 120 | 121 | - Upgrade language server to 3.2.3 122 | 123 | ## 1.16.1 124 | 125 | - Fix incorrect link in README 126 | 127 | ## 1.16.0 128 | 129 | - Upgrade language server to 3.2.0 130 | 131 | ## 1.15.0 132 | 133 | - Upgrade language server to 3.1.0 134 | 135 | ## 1.14.0 136 | 137 | - Upgrade language server to 3.0.3 that includes support for Shellcheck linting (please follow https://github.com/koalaman/shellcheck#installing to install Shellcheck) 138 | 139 | ## 1.13.0 140 | 141 | - Upgrade language server to 2.1.0 142 | 143 | ## 1.12.1 144 | 145 | - Bug fix to fix server not starting 146 | 147 | ## 1.12.0 148 | 149 | - Upgrade language server to 2.0.0 150 | 151 | ## 1.11.0 152 | 153 | - Default configuration change: parsing errors are not highlighted as problems (as the grammar is buggy) 154 | 155 | ## 1.10.2 156 | 157 | - Upgrade language server to 1.16.1 (fix brace expansion bug and crash when bash is not installed) 158 | 159 | ## 1.10.1 160 | 161 | - Upgrade language server to 1.16.0 (improved completion support for parameters) 162 | 163 | ## 1.10.0 164 | 165 | - Upgrade language server to 1.15.0 (improved hover and completion documentation) 166 | 167 | ## 1.9.1 168 | 169 | - Upgrade language server to 1.13.1 (improved file lookup error handling) 170 | 171 | ## 1.9.0 172 | 173 | - Upgrade language server to 1.13.0 (improved completion handler with suggestions based on variables and functions found in the workspace) 174 | 175 | ## 1.8.0 176 | 177 | - Upgrade language server to 1.11.1 (support for workspace symbols). This can for example be used by doing `Command + P` and then write `# someSearchQuery` 178 | 179 | ## 1.7.0 180 | 181 | - Upgrade language server to 1.10.0 (improved completion handler) 182 | 183 | ## 1.6.0 184 | 185 | - Upgrade language server to 1.9.0 (skip analyzing files with a non-bash shebang) 186 | 187 | ## 1.5.0 188 | 189 | - Upgrade language server to 1.8.0 (PATH tilde expansion, builtins and man pages formatting, pre-analyzes more files than just .sh) 190 | 191 | * Make file glob configurable 192 | 193 | - Remove unused `bashIde.path` configuration parameter 194 | 195 | ## 1.4.0 196 | 197 | - Remove additional installation step by integrating the `bash-language-server` (version 1.6.1) 198 | 199 | ## 1.3.3 200 | 201 | - Force people to upgrade their `bash-language-server` installation to `1.5.2`. 202 | 203 | ## 1.3.2 204 | 205 | - Added a new configuration option `bashIde.highlightParsingErrors` which defaults 206 | to `true`. When enabled it will report parsing errors as 'problems'. This means you 207 | can now disable the error reporting which is convenient as `shellcheck` performs a 208 | better job of linting and our parser still has many issues. 209 | 210 | ## 1.3.1 211 | 212 | - Added new configuration option `bashIde.path` for specifying the exact 213 | location of the binary. 214 | - Added new configuration option `bashIde.explainshellEndpoint` that you can use 215 | to enable explainshell integration. 216 | 217 | ## 1.3.0 218 | 219 | - The client will now prompt to upgrade the Bash Language Server if you're running 220 | an old version. 221 | 222 | ## 1.2.1 223 | 224 | - Attempt to support windows by appending `cmd` to the command to start the 225 | server. 226 | -------------------------------------------------------------------------------- /vscode-client/README.md: -------------------------------------------------------------------------------- 1 | # Bash IDE 2 | 3 | [![VS Marketplace installs](https://badgen.net/vs-marketplace/i/mads-hartmann.bash-ide-vscode?label=VS%20Marketplace%20installs)](https://marketplace.visualstudio.com/items?itemName=mads-hartmann.bash-ide-vscode) 4 | [![VS Marketplace downloads](https://badgen.net/vs-marketplace/d/mads-hartmann.bash-ide-vscode?label=VS%20Marketplace%20downloads)](https://marketplace.visualstudio.com/items?itemName=mads-hartmann.bash-ide-vscode) 5 | [![Open VSX downloads](https://badgen.net/open-vsx/d/mads-hartmann/bash-ide-vscode?color=purple&label=Open%20VSX%20downloads)](https://open-vsx.org/extension/mads-hartmann/bash-ide-vscode) 6 | 7 | Visual Studio Code extension utilizing the [Bash Language Server][bash-lsp] and integrating with [explainshell][explainshell], [shellcheck][shellcheck] and [shfmt][shfmt]. 8 | 9 | We recommend that you [install shellcheck](https://github.com/koalaman/shellcheck#installing) to enable linting and [install shfmt](https://github.com/mvdan/sh?tab=readme-ov-file#shfmt) to enable formatting. 10 | 11 | ## Features 12 | 13 | - [x] Jump to declaration 14 | - [x] Find references 15 | - [x] Code Outline & Show Symbols 16 | - [x] Highlight occurrences 17 | - [x] Code completion 18 | - [x] Simple diagnostics reporting 19 | - [x] Documentation for flags on hover 20 | - [x] Workspace symbols 21 | - [x] Rename symbol 22 | - [x] Format document 23 | - [x] Snippets 24 | 25 | ## Configuration 26 | 27 | To get documentation for flags on hover (thanks to explainshell), run a explainshell server and update your VS Code settings: 28 | 29 | ``` 30 | "bashIde.explainshellEndpoint": "http://localhost:5000", 31 | ``` 32 | 33 | For security reasons, it defaults to `""`, which disables explainshell integration. When set, this extension will send requests to the endpoint and displays documentation for flags. We recommend using a local Docker image (see https://github.com/bash-lsp/bash-language-server/issues/180). 34 | 35 | [bash-lsp]: https://github.com/bash-lsp/bash-language-server 36 | [tree-sitter]: https://github.com/tree-sitter/tree-sitter 37 | [tree-sitter-bash]: https://github.com/tree-sitter/tree-sitter-bash 38 | [explainshell]: https://explainshell.com/ 39 | [shellcheck]: https://www.shellcheck.net/ 40 | [shfmt]: https://github.com/mvdan/sh 41 | -------------------------------------------------------------------------------- /vscode-client/__tests__/config.test.ts: -------------------------------------------------------------------------------- 1 | const packageJson = require('../package.json') 2 | import { getDefaultConfiguration } from '../../server/src/config' 3 | import { LOG_LEVELS } from '../../server/src/util/logger' 4 | 5 | const defaultConfig = getDefaultConfiguration() 6 | 7 | function flattenObjectKeys(obj: Record, prefix = '') { 8 | return Object.keys(obj).reduce((flattenedKeys: string[], key) => { 9 | const pre = prefix.length ? `${prefix}.` : '' 10 | if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) { 11 | flattenedKeys.push(...flattenObjectKeys(obj[key], pre + key)) 12 | } else { 13 | flattenedKeys.push(pre + key) 14 | } 15 | return flattenedKeys 16 | }, []) 17 | } 18 | 19 | describe('config', () => { 20 | const configProperties = packageJson.contributes.configuration.properties 21 | 22 | it('prefixes all keys with "bashIde."', () => { 23 | for (const key of Object.keys(configProperties)) { 24 | expect(key).toMatch(/^bashIde\./) 25 | } 26 | }) 27 | 28 | it('has the same keys as the default configuration', () => { 29 | const configKeys = Object.keys(configProperties) 30 | .map((key) => key.replace(/^bashIde\./, '')) 31 | .sort() 32 | 33 | const defaultConfigKeys = flattenObjectKeys(defaultConfig).sort() 34 | expect(configKeys).toEqual(defaultConfigKeys) 35 | }) 36 | 37 | it('matches the server log levels', () => { 38 | const configLogLevels = configProperties['bashIde.logLevel'].enum?.sort() 39 | expect(configLogLevels).toEqual(LOG_LEVELS.slice().sort()) 40 | expect(LOG_LEVELS).toContain(configProperties['bashIde.logLevel'].default) 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /vscode-client/assets/bash-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash-lsp/bash-language-server/71218fc93c597173b93534815207fb40acef1cb8/vscode-client/assets/bash-logo.png -------------------------------------------------------------------------------- /vscode-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bash-ide-vscode", 3 | "displayName": "Bash IDE", 4 | "description": "A language server for Bash", 5 | "author": "Mads Hartmann", 6 | "license": "MIT", 7 | "version": "1.43.0", 8 | "publisher": "mads-hartmann", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/bash-lsp/bash-language-server" 12 | }, 13 | "engines": { 14 | "vscode": "^1.44.0" 15 | }, 16 | "icon": "assets/bash-logo.png", 17 | "categories": [ 18 | "Other" 19 | ], 20 | "keywords": [ 21 | "shell script", 22 | "bash script", 23 | "bash" 24 | ], 25 | "activationEvents": [ 26 | "onLanguage:shellscript" 27 | ], 28 | "main": "./out/extension", 29 | "contributes": { 30 | "configuration": { 31 | "type": "object", 32 | "title": "Bash IDE configuration", 33 | "properties": { 34 | "bashIde.backgroundAnalysisMaxFiles": { 35 | "type": "number", 36 | "default": 500, 37 | "description": "Maximum number of files to analyze in the background. Set to 0 to disable background analysis.", 38 | "minimum": 0 39 | }, 40 | "bashIde.enableSourceErrorDiagnostics": { 41 | "type": "boolean", 42 | "default": false, 43 | "description": "Enable diagnostics for source errors. Ignored if includeAllWorkspaceSymbols is true." 44 | }, 45 | "bashIde.explainshellEndpoint": { 46 | "type": "string", 47 | "default": "", 48 | "description": "Configure explainshell server endpoint in order to get hover documentation on flags and options." 49 | }, 50 | "bashIde.globPattern": { 51 | "type": "string", 52 | "default": "**/*@(.sh|.inc|.bash|.command)", 53 | "description": "Glob pattern for finding and parsing shell script files in the workspace. Used by the background analysis features across files." 54 | }, 55 | "bashIde.includeAllWorkspaceSymbols": { 56 | "type": "boolean", 57 | "default": false, 58 | "description": "Controls how symbols (e.g. variables and functions) are included and used for completion, documentation, and renaming. If false (default and recommended), then we only include symbols from sourced files (i.e. using non dynamic statements like 'source file.sh' or '. file.sh' or following ShellCheck directives). If true, then all symbols from the workspace are included." 59 | }, 60 | "bashIde.logLevel": { 61 | "type": "string", 62 | "default": "info", 63 | "enum": [ 64 | "debug", 65 | "info", 66 | "warning", 67 | "error" 68 | ], 69 | "description": "Controls the log level of the language server." 70 | }, 71 | "bashIde.shellcheckPath": { 72 | "type": "string", 73 | "default": "shellcheck", 74 | "description": "Controls the executable used for ShellCheck linting information. An empty string will disable linting." 75 | }, 76 | "bashIde.shellcheckArguments": { 77 | "type": "string", 78 | "default": "", 79 | "description": "Additional ShellCheck arguments. Note that we already add the following arguments: --shell, --format, --external-sources." 80 | }, 81 | "bashIde.shfmt.path": { 82 | "type": "string", 83 | "default": "shfmt", 84 | "description": "Controls the executable used for Shfmt formatting. An empty string will disable formatting." 85 | }, 86 | "bashIde.shfmt.ignoreEditorconfig": { 87 | "type": "boolean", 88 | "default": false, 89 | "description": "Ignore shfmt config options in .editorconfig (always use language server config)" 90 | }, 91 | "bashIde.shfmt.languageDialect": { 92 | "type": "string", 93 | "default": "auto", 94 | "enum": [ 95 | "auto", 96 | "bash", 97 | "posix", 98 | "mksh", 99 | "bats" 100 | ], 101 | "description": "Language dialect to use when parsing (bash/posix/mksh/bats)." 102 | }, 103 | "bashIde.shfmt.binaryNextLine": { 104 | "type": "boolean", 105 | "default": false, 106 | "description": "Allow boolean operators (like && and ||) to start a line." 107 | }, 108 | "bashIde.shfmt.caseIndent": { 109 | "type": "boolean", 110 | "default": false, 111 | "description": "Indent patterns in case statements." 112 | }, 113 | "bashIde.shfmt.funcNextLine": { 114 | "type": "boolean", 115 | "default": false, 116 | "description": "Place function opening braces on a separate line." 117 | }, 118 | "bashIde.shfmt.keepPadding": { 119 | "type": "boolean", 120 | "default": false, 121 | "description": "(Deprecated) Keep column alignment padding.", 122 | "markdownDescription": "**([Deprecated](https://github.com/mvdan/sh/issues/658))** Keep column alignment padding." 123 | }, 124 | "bashIde.shfmt.simplifyCode": { 125 | "type": "boolean", 126 | "default": false, 127 | "description": "Simplify code before formatting." 128 | }, 129 | "bashIde.shfmt.spaceRedirects": { 130 | "type": "boolean", 131 | "default": false, 132 | "description": "Follow redirection operators with a space." 133 | } 134 | } 135 | } 136 | }, 137 | "scripts": { 138 | "vscode:prepublish": "cd .. && pnpm compile" 139 | }, 140 | "dependencies": { 141 | "bash-language-server": "5.4.2", 142 | "vscode-languageclient": "8.1.0", 143 | "vscode-languageserver": "8.0.2" 144 | }, 145 | "devDependencies": { 146 | "@types/vscode": "^1.44.0" 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /vscode-client/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: 10 | dependencies: 11 | bash-language-server: 12 | specifier: 5.4.2 13 | version: 5.4.2 14 | vscode-languageclient: 15 | specifier: 8.1.0 16 | version: 8.1.0 17 | vscode-languageserver: 18 | specifier: 8.0.2 19 | version: 8.0.2 20 | devDependencies: 21 | '@types/vscode': 22 | specifier: ^1.44.0 23 | version: 1.44.0 24 | 25 | packages: 26 | 27 | '@nodelib/fs.scandir@2.1.5': 28 | resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} 29 | engines: {node: '>= 8'} 30 | 31 | '@nodelib/fs.stat@2.0.5': 32 | resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} 33 | engines: {node: '>= 8'} 34 | 35 | '@nodelib/fs.walk@1.2.8': 36 | resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} 37 | engines: {node: '>= 8'} 38 | 39 | '@one-ini/wasm@0.1.1': 40 | resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} 41 | 42 | '@types/vscode@1.44.0': 43 | resolution: {integrity: sha512-WJZtZlinE3meRdH+I7wTsIhpz/GLhqEQwmPGeh4s1irWLwMzCeTV8WZ+pgPTwrDXoafVUWwo1LiZ9HJVHFlJSQ==} 44 | 45 | balanced-match@1.0.2: 46 | resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} 47 | 48 | bash-language-server@5.4.2: 49 | resolution: {integrity: sha512-3N2INRlio63A5CwvDz2sZEWFp46H3sm7OD2IU/kr8MSYKAIRt/xD23arT7daJfYKLC+I6ijWsvaFs9tP6Bo00Q==} 50 | engines: {node: '>=16'} 51 | hasBin: true 52 | 53 | brace-expansion@2.0.1: 54 | resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} 55 | 56 | braces@3.0.3: 57 | resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} 58 | engines: {node: '>=8'} 59 | 60 | commander@11.1.0: 61 | resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} 62 | engines: {node: '>=16'} 63 | 64 | domino@2.1.6: 65 | resolution: {integrity: sha512-3VdM/SXBZX2omc9JF9nOPCtDaYQ67BGp5CoLpIQlO2KCAPETs8TcDHacF26jXadGbvUteZzRTeos2fhID5+ucQ==} 66 | 67 | editorconfig@2.0.0: 68 | resolution: {integrity: sha512-s1NQ63WQ7RNXH6Efb2cwuyRlfpbtdZubvfNe4vCuoyGPewNPY7vah8JUSOFBiJ+jr99Qh8t0xKv0oITc1dclgw==} 69 | engines: {node: '>=16'} 70 | hasBin: true 71 | 72 | fast-glob@3.3.2: 73 | resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} 74 | engines: {node: '>=8.6.0'} 75 | 76 | fastq@1.15.0: 77 | resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} 78 | 79 | fill-range@7.1.1: 80 | resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} 81 | engines: {node: '>=8'} 82 | 83 | fuzzy-search@3.2.1: 84 | resolution: {integrity: sha512-vAcPiyomt1ioKAsAL2uxSABHJ4Ju/e4UeDM+g1OlR0vV4YhLGMNsdLNvZTpEDY4JCSt0E4hASCNM5t2ETtsbyg==} 85 | 86 | glob-parent@5.1.2: 87 | resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} 88 | engines: {node: '>= 6'} 89 | 90 | is-extglob@2.1.1: 91 | resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} 92 | engines: {node: '>=0.10.0'} 93 | 94 | is-glob@4.0.3: 95 | resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} 96 | engines: {node: '>=0.10.0'} 97 | 98 | is-number@7.0.0: 99 | resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} 100 | engines: {node: '>=0.12.0'} 101 | 102 | lru-cache@6.0.0: 103 | resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} 104 | engines: {node: '>=10'} 105 | 106 | merge2@1.4.1: 107 | resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} 108 | engines: {node: '>= 8'} 109 | 110 | micromatch@4.0.7: 111 | resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==} 112 | engines: {node: '>=8.6'} 113 | 114 | minimatch@5.1.6: 115 | resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} 116 | engines: {node: '>=10'} 117 | 118 | minimatch@9.0.2: 119 | resolution: {integrity: sha512-PZOT9g5v2ojiTL7r1xF6plNHLtOeTpSlDI007As2NlA2aYBMfVom17yqa6QzhmDP8QOhn7LjHTg7DFCVSSa6yg==} 120 | engines: {node: '>=16 || 14 >=14.17'} 121 | 122 | node-fetch@2.7.0: 123 | resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} 124 | engines: {node: 4.x || >=6.0.0} 125 | peerDependencies: 126 | encoding: ^0.1.0 127 | peerDependenciesMeta: 128 | encoding: 129 | optional: true 130 | 131 | picomatch@2.3.1: 132 | resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} 133 | engines: {node: '>=8.6'} 134 | 135 | queue-microtask@1.2.3: 136 | resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} 137 | 138 | reusify@1.0.4: 139 | resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} 140 | engines: {iojs: '>=1.0.0', node: '>=0.10.0'} 141 | 142 | run-parallel@1.2.0: 143 | resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} 144 | 145 | semver@7.5.4: 146 | resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} 147 | engines: {node: '>=10'} 148 | hasBin: true 149 | 150 | to-regex-range@5.0.1: 151 | resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} 152 | engines: {node: '>=8.0'} 153 | 154 | tr46@0.0.3: 155 | resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} 156 | 157 | turndown@7.1.3: 158 | resolution: {integrity: sha512-Z3/iJ6IWh8VBiACWQJaA5ulPQE5E1QwvBHj00uGzdQxdRnd8fh1DPqNOJqzQDu6DkOstORrtXzf/9adB+vMtEA==} 159 | 160 | vscode-jsonrpc@8.0.2: 161 | resolution: {integrity: sha512-RY7HwI/ydoC1Wwg4gJ3y6LpU9FJRZAUnTYMXthqhFXXu77ErDd/xkREpGuk4MyYkk4a+XDWAMqe0S3KkelYQEQ==} 162 | engines: {node: '>=14.0.0'} 163 | 164 | vscode-jsonrpc@8.1.0: 165 | resolution: {integrity: sha512-6TDy/abTQk+zDGYazgbIPc+4JoXdwC8NHU9Pbn4UJP1fehUyZmM4RHp5IthX7A6L5KS30PRui+j+tbbMMMafdw==} 166 | engines: {node: '>=14.0.0'} 167 | 168 | vscode-languageclient@8.1.0: 169 | resolution: {integrity: sha512-GL4QdbYUF/XxQlAsvYWZRV3V34kOkpRlvV60/72ghHfsYFnS/v2MANZ9P6sHmxFcZKOse8O+L9G7Czg0NUWing==} 170 | engines: {vscode: ^1.67.0} 171 | 172 | vscode-languageserver-protocol@3.17.2: 173 | resolution: {integrity: sha512-8kYisQ3z/SQ2kyjlNeQxbkkTNmVFoQCqkmGrzLH6A9ecPlgTbp3wDTnUNqaUxYr4vlAcloxx8zwy7G5WdguYNg==} 174 | 175 | vscode-languageserver-protocol@3.17.3: 176 | resolution: {integrity: sha512-924/h0AqsMtA5yK22GgMtCYiMdCOtWTSGgUOkgEDX+wk2b0x4sAfLiO4NxBxqbiVtz7K7/1/RgVrVI0NClZwqA==} 177 | 178 | vscode-languageserver-textdocument@1.0.12: 179 | resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} 180 | 181 | vscode-languageserver-types@3.17.2: 182 | resolution: {integrity: sha512-zHhCWatviizPIq9B7Vh9uvrH6x3sK8itC84HkamnBWoDFJtzBf7SWlpLCZUit72b3os45h6RWQNC9xHRDF8dRA==} 183 | 184 | vscode-languageserver-types@3.17.3: 185 | resolution: {integrity: sha512-SYU4z1dL0PyIMd4Vj8YOqFvHu7Hz/enbWtpfnVbJHU4Nd1YNYx8u0ennumc6h48GQNeOLxmwySmnADouT/AuZA==} 186 | 187 | vscode-languageserver@8.0.2: 188 | resolution: {integrity: sha512-bpEt2ggPxKzsAOZlXmCJ50bV7VrxwCS5BI4+egUmure/oI/t4OlFzi/YNtVvY24A2UDOZAgwFGgnZPwqSJubkA==} 189 | hasBin: true 190 | 191 | web-tree-sitter@0.23.0: 192 | resolution: {integrity: sha512-p1T+ju2H30fpVX2q5yr+Wv/NfdMMWMjQp9Q+4eEPrHAJpPFh9DPfI2Yr9L1f5SA5KPE+g1cNUqPbpihxUDzmVw==} 193 | 194 | webidl-conversions@3.0.1: 195 | resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} 196 | 197 | whatwg-url@5.0.0: 198 | resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} 199 | 200 | yallist@4.0.0: 201 | resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} 202 | 203 | zod@3.22.4: 204 | resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} 205 | 206 | snapshots: 207 | 208 | '@nodelib/fs.scandir@2.1.5': 209 | dependencies: 210 | '@nodelib/fs.stat': 2.0.5 211 | run-parallel: 1.2.0 212 | 213 | '@nodelib/fs.stat@2.0.5': {} 214 | 215 | '@nodelib/fs.walk@1.2.8': 216 | dependencies: 217 | '@nodelib/fs.scandir': 2.1.5 218 | fastq: 1.15.0 219 | 220 | '@one-ini/wasm@0.1.1': {} 221 | 222 | '@types/vscode@1.44.0': {} 223 | 224 | balanced-match@1.0.2: {} 225 | 226 | bash-language-server@5.4.2: 227 | dependencies: 228 | editorconfig: 2.0.0 229 | fast-glob: 3.3.2 230 | fuzzy-search: 3.2.1 231 | node-fetch: 2.7.0 232 | turndown: 7.1.3 233 | vscode-languageserver: 8.0.2 234 | vscode-languageserver-textdocument: 1.0.12 235 | web-tree-sitter: 0.23.0 236 | zod: 3.22.4 237 | transitivePeerDependencies: 238 | - encoding 239 | 240 | brace-expansion@2.0.1: 241 | dependencies: 242 | balanced-match: 1.0.2 243 | 244 | braces@3.0.3: 245 | dependencies: 246 | fill-range: 7.1.1 247 | 248 | commander@11.1.0: {} 249 | 250 | domino@2.1.6: {} 251 | 252 | editorconfig@2.0.0: 253 | dependencies: 254 | '@one-ini/wasm': 0.1.1 255 | commander: 11.1.0 256 | minimatch: 9.0.2 257 | semver: 7.5.4 258 | 259 | fast-glob@3.3.2: 260 | dependencies: 261 | '@nodelib/fs.stat': 2.0.5 262 | '@nodelib/fs.walk': 1.2.8 263 | glob-parent: 5.1.2 264 | merge2: 1.4.1 265 | micromatch: 4.0.7 266 | 267 | fastq@1.15.0: 268 | dependencies: 269 | reusify: 1.0.4 270 | 271 | fill-range@7.1.1: 272 | dependencies: 273 | to-regex-range: 5.0.1 274 | 275 | fuzzy-search@3.2.1: {} 276 | 277 | glob-parent@5.1.2: 278 | dependencies: 279 | is-glob: 4.0.3 280 | 281 | is-extglob@2.1.1: {} 282 | 283 | is-glob@4.0.3: 284 | dependencies: 285 | is-extglob: 2.1.1 286 | 287 | is-number@7.0.0: {} 288 | 289 | lru-cache@6.0.0: 290 | dependencies: 291 | yallist: 4.0.0 292 | 293 | merge2@1.4.1: {} 294 | 295 | micromatch@4.0.7: 296 | dependencies: 297 | braces: 3.0.3 298 | picomatch: 2.3.1 299 | 300 | minimatch@5.1.6: 301 | dependencies: 302 | brace-expansion: 2.0.1 303 | 304 | minimatch@9.0.2: 305 | dependencies: 306 | brace-expansion: 2.0.1 307 | 308 | node-fetch@2.7.0: 309 | dependencies: 310 | whatwg-url: 5.0.0 311 | 312 | picomatch@2.3.1: {} 313 | 314 | queue-microtask@1.2.3: {} 315 | 316 | reusify@1.0.4: {} 317 | 318 | run-parallel@1.2.0: 319 | dependencies: 320 | queue-microtask: 1.2.3 321 | 322 | semver@7.5.4: 323 | dependencies: 324 | lru-cache: 6.0.0 325 | 326 | to-regex-range@5.0.1: 327 | dependencies: 328 | is-number: 7.0.0 329 | 330 | tr46@0.0.3: {} 331 | 332 | turndown@7.1.3: 333 | dependencies: 334 | domino: 2.1.6 335 | 336 | vscode-jsonrpc@8.0.2: {} 337 | 338 | vscode-jsonrpc@8.1.0: {} 339 | 340 | vscode-languageclient@8.1.0: 341 | dependencies: 342 | minimatch: 5.1.6 343 | semver: 7.5.4 344 | vscode-languageserver-protocol: 3.17.3 345 | 346 | vscode-languageserver-protocol@3.17.2: 347 | dependencies: 348 | vscode-jsonrpc: 8.0.2 349 | vscode-languageserver-types: 3.17.2 350 | 351 | vscode-languageserver-protocol@3.17.3: 352 | dependencies: 353 | vscode-jsonrpc: 8.1.0 354 | vscode-languageserver-types: 3.17.3 355 | 356 | vscode-languageserver-textdocument@1.0.12: {} 357 | 358 | vscode-languageserver-types@3.17.2: {} 359 | 360 | vscode-languageserver-types@3.17.3: {} 361 | 362 | vscode-languageserver@8.0.2: 363 | dependencies: 364 | vscode-languageserver-protocol: 3.17.2 365 | 366 | web-tree-sitter@0.23.0: {} 367 | 368 | webidl-conversions@3.0.1: {} 369 | 370 | whatwg-url@5.0.0: 371 | dependencies: 372 | tr46: 0.0.3 373 | webidl-conversions: 3.0.1 374 | 375 | yallist@4.0.0: {} 376 | 377 | zod@3.22.4: {} 378 | -------------------------------------------------------------------------------- /vscode-client/src/extension.ts: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import * as path from 'path' 4 | import { ExtensionContext, workspace } from 'vscode' 5 | import { 6 | LanguageClient, 7 | LanguageClientOptions, 8 | ServerOptions, 9 | TransportKind, 10 | } from 'vscode-languageclient/node' 11 | 12 | let client: LanguageClient 13 | 14 | export const CONFIGURATION_SECTION = 'bashIde' // matching the package.json configuration section 15 | 16 | export async function activate(context: ExtensionContext) { 17 | const config = workspace.getConfiguration(CONFIGURATION_SECTION) 18 | const env: any = { 19 | ...process.env, 20 | BASH_IDE_LOG_LEVEL: config.get('logLevel', ''), 21 | } 22 | 23 | const serverExecutable = { 24 | module: context.asAbsolutePath(path.join('out', 'server.js')), 25 | transport: TransportKind.ipc, 26 | options: { 27 | env, 28 | }, 29 | } 30 | 31 | const debugServerExecutable = { 32 | ...serverExecutable, 33 | options: { 34 | ...serverExecutable.options, 35 | execArgv: ['--nolazy', '--inspect=6009'], 36 | }, 37 | } 38 | 39 | const serverOptions: ServerOptions = { 40 | run: serverExecutable, 41 | debug: debugServerExecutable, 42 | } 43 | 44 | // NOTE: To debug a server running in a process, use the following instead: 45 | // This requires the server to be globally installed. 46 | // const serverOptions = { 47 | // command: 'bash-language-server', 48 | // args: ['start'], 49 | // } 50 | 51 | const clientOptions: LanguageClientOptions = { 52 | documentSelector: [ 53 | { 54 | scheme: 'file', 55 | language: 'shellscript', 56 | }, 57 | ], 58 | synchronize: { 59 | configurationSection: CONFIGURATION_SECTION, 60 | }, 61 | } 62 | 63 | const client = new LanguageClient('Bash IDE', 'Bash IDE', serverOptions, clientOptions) 64 | client.registerProposedFeatures() 65 | 66 | try { 67 | await client.start() 68 | } catch (error) { 69 | client.error(`Start failed`, error, 'force') 70 | } 71 | } 72 | 73 | export function deactivate(): Thenable | undefined { 74 | if (!client) { 75 | return undefined 76 | } 77 | return client.stop() 78 | } 79 | -------------------------------------------------------------------------------- /vscode-client/src/server.ts: -------------------------------------------------------------------------------- 1 | import BashLanguageServer from 'bash-language-server' 2 | import { 3 | createConnection, 4 | InitializeParams, 5 | InitializeResult, 6 | ProposedFeatures, 7 | } from 'vscode-languageserver/node' 8 | 9 | const connection = createConnection(ProposedFeatures.all) 10 | 11 | connection.onInitialize(async (params: InitializeParams): Promise => { 12 | const server = await BashLanguageServer.initialize(connection, params) 13 | server.register(connection) 14 | return { 15 | capabilities: server.capabilities(), 16 | } 17 | }) 18 | 19 | connection.listen() 20 | 21 | // Don't die on unhandled Promise rejections 22 | process.on('unhandledRejection', (reason, p) => { 23 | const stack = reason instanceof Error ? reason.stack : reason 24 | connection.console.error(`Unhandled Rejection at promise: ${p}, reason: ${stack}`) 25 | }) 26 | 27 | process.on('SIGPIPE', () => { 28 | // Don't die when attempting to pipe stdin to a bad spawn 29 | // https://github.com/electron/electron/issues/13254 30 | }) 31 | -------------------------------------------------------------------------------- /vscode-client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "out", 5 | "rootDir": "src" 6 | }, 7 | "include": ["src"], 8 | "exclude": ["node_modules", "../testing"] 9 | } 10 | --------------------------------------------------------------------------------