├── .envrc ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── add-to-project.yaml │ ├── build.yaml │ ├── emergency-review-bypass.yaml │ ├── notify-approval-bypass.yaml │ └── pr-title.yaml ├── .gitignore ├── .nvmrc ├── .prettierrc ├── .vscode-test.mjs ├── .vscode ├── launch.json └── tasks.json ├── .vscodeignore ├── DEVELOPMENT.md ├── LICENSE ├── Makefile ├── README.md ├── eslint.config.mjs ├── lint_errors.png ├── logo.png ├── package-lock.json ├── package.json ├── playwright.config.ts ├── protobuf-language-configuration.json ├── scripts └── gh-diffcheck.mjs ├── src ├── commands │ ├── buf-generate.ts │ ├── command.ts │ ├── find-buf.ts │ ├── index.ts │ ├── install-buf.ts │ ├── register-all-commands.ts │ ├── restart-buf.ts │ ├── show-commands.ts │ ├── show-output.ts │ ├── stop-buf.ts │ └── update-buf.ts ├── config.ts ├── const.ts ├── context.ts ├── errors.ts ├── extension.ts ├── github.ts ├── log.ts ├── status.ts ├── ui.ts ├── util.ts └── version.ts ├── syntaxes ├── proto.tmLanguage.LICENSE └── proto.tmLanguage.json ├── test-workspaces └── npm-buf-workspace │ ├── .vscode │ └── settings.json │ ├── package-lock.json │ └── package.json ├── test ├── e2e │ ├── base-test.ts │ ├── extension.test.ts │ └── global-setup.ts ├── mocks │ └── mock-context.ts ├── stubs │ ├── stub-github.ts │ ├── stub-log.ts │ └── stub-vscode.ts └── unit │ ├── commands │ ├── buf-generate.test.ts │ ├── find-buf.test.ts │ ├── install-buf.test.ts │ ├── restart-buf.test.ts │ └── update-buf.test.ts │ ├── context.test.ts │ ├── extension.test.ts │ ├── github.test.ts │ ├── index.ts │ ├── status.test.ts │ └── version.test.ts └── tsconfig.json /.envrc: -------------------------------------------------------------------------------- 1 | if [ -f .env/env.sh ]; then 2 | . .env/env.sh 3 | fi 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # This is similar to the git option core.autocrlf but it applies to all 2 | # users of the repository and therefore doesn't depend on a developers 3 | # local configuration. 4 | * text=auto eol=lf -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/add-to-project.yaml: -------------------------------------------------------------------------------- 1 | name: Add issues and PRs to project 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | - reopened 8 | - transferred 9 | pull_request_target: 10 | types: 11 | - opened 12 | - reopened 13 | issue_comment: 14 | types: 15 | - created 16 | 17 | jobs: 18 | call-workflow-add-to-project: 19 | name: Call workflow to add issue to project 20 | uses: bufbuild/base-workflows/.github/workflows/add-to-project.yaml@main 21 | secrets: inherit 22 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: push 3 | 4 | jobs: 5 | build: 6 | strategy: 7 | matrix: 8 | os: 9 | - macos-latest 10 | - ubuntu-latest 11 | - windows-latest 12 | runs-on: ${{ matrix.os }} 13 | steps: 14 | - name: checkout 15 | uses: actions/checkout@v2 16 | - name: setup-node 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version-file: ".nvmrc" 20 | cache: npm 21 | - uses: bufbuild/buf-action@v1 22 | with: 23 | setup_only: true 24 | - name: install-deps 25 | run: make install 26 | - name: build 27 | run: npm run compile 28 | - name: lint 29 | run: npm run lint 30 | - name: format 31 | run: npm run format 32 | - name: unit-tests-with-xvfb 33 | run: xvfb-run -a npm run test:unit 34 | if: runner.os == 'Linux' 35 | - name: unit-test 36 | run: npm run test:unit 37 | if: runner.os != 'Linux' 38 | - name: e2e-test 39 | run: npm run test:e2e 40 | # Limiting to macOS for now due to issues with xvfb on Linux and timeouts on windows 41 | if: runner.os == 'macOS' 42 | - name: check diff 43 | run: node scripts/gh-diffcheck.mjs 44 | - name: upload-e2e-test-results 45 | uses: actions/upload-artifact@v4 46 | if: always() 47 | with: 48 | name: test-results 49 | path: test-results/ 50 | retention-days: 5 51 | if-no-files-found: ignore 52 | -------------------------------------------------------------------------------- /.github/workflows/emergency-review-bypass.yaml: -------------------------------------------------------------------------------- 1 | name: Bypass review in case of emergency 2 | on: 3 | pull_request: 4 | types: 5 | - labeled 6 | permissions: 7 | pull-requests: write 8 | jobs: 9 | approve: 10 | if: github.event.label.name == 'Emergency Bypass Review' 11 | uses: bufbuild/base-workflows/.github/workflows/emergency-review-bypass.yaml@main 12 | secrets: inherit 13 | -------------------------------------------------------------------------------- /.github/workflows/notify-approval-bypass.yaml: -------------------------------------------------------------------------------- 1 | name: PR Approval Bypass Notifier 2 | on: 3 | pull_request: 4 | types: 5 | - closed 6 | branches: 7 | - main 8 | permissions: 9 | pull-requests: read 10 | jobs: 11 | approval: 12 | uses: bufbuild/base-workflows/.github/workflows/notify-approval-bypass.yaml@main 13 | secrets: inherit 14 | -------------------------------------------------------------------------------- /.github/workflows/pr-title.yaml: -------------------------------------------------------------------------------- 1 | name: Lint PR Title 2 | # Prevent writing to the repository using the CI token. 3 | # Ref: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#permissions 4 | permissions: 5 | pull-requests: read 6 | on: 7 | pull_request: 8 | # By default, a workflow only runs when a pull_request's activity type is opened, 9 | # synchronize, or reopened. We explicity override here so that PR titles are 10 | # re-linted when the PR text content is edited. 11 | types: 12 | - opened 13 | - edited 14 | - reopened 15 | - synchronize 16 | jobs: 17 | lint: 18 | uses: bufbuild/base-workflows/.github/workflows/pr-title.yaml@main 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | node_modules 4 | .vscode-test/ 5 | *.vsix 6 | .env/ 7 | .user-data-dir-test 8 | test-results 9 | playwright-report -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.14.0 -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5" 3 | } -------------------------------------------------------------------------------- /.vscode-test.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@vscode/test-cli"; 2 | 3 | export default defineConfig([ 4 | { 5 | label: "unitTests", 6 | files: "out/test/unit/**/*.test.js", 7 | workspaceFolder: "./test-workspaces/npm-buf-workspace", 8 | }, 9 | ]); 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "args": [ 13 | "--disable-extensions", 14 | "--extensionDevelopmentPath=${workspaceFolder}" 15 | ], 16 | "outFiles": ["${workspaceFolder}/out/**/*.js"], 17 | "preLaunchTask": "npm: watch" 18 | }, 19 | { 20 | "name": "Extension Tests", 21 | "type": "extensionHost", 22 | "request": "launch", 23 | "runtimeExecutable": "${execPath}", 24 | "args": [ 25 | "--disable-extensions", 26 | "--profile-temp", 27 | "--extensionDevelopmentPath=${workspaceFolder}", 28 | "--extensionTestsPath=${workspaceFolder}/out/test/unit/index", 29 | "--user-data-dir=${workspaceFolder}/extension/.user-data-dir-test", 30 | "--timeout", 31 | "999999" 32 | ], 33 | "outFiles": ["${workspaceFolder}/out/test/**/*.js"], 34 | "sourceMaps": true, 35 | "smartStep": true, 36 | "preLaunchTask": "npm: watch", 37 | "cwd": "${workspaceFolder}/extension" 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "npm: watch", 8 | "type": "npm", 9 | "script": "watch", 10 | "problemMatcher": "$tsc-watch", 11 | "isBackground": true, 12 | "presentation": { 13 | "reveal": "never" 14 | }, 15 | "group": { 16 | "kind": "build", 17 | "isDefault": true 18 | } 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | out/test/** 4 | src/** 5 | .gitignore 6 | .yarnrc 7 | vsc-extension-quickstart.md 8 | **/tsconfig.json 9 | **/.eslintrc.json 10 | **/*.map 11 | **/*.ts 12 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | ## Running locally 2 | 3 | Open the directory in VS Code and run by pressing F5. This will start a new instance of VS code with the plugin loaded. 4 | 5 | ## Running tests 6 | 7 | Open the debug tab, switch from `Run Extension` to `Run Extension Tests`. 8 | Press F5 to run the tests. 9 | 10 | ## Building the extension 11 | 12 | To build the extension, run `make`. This produces a `*.vsix` file, which 13 | can be manually installed to an instance of VSCode by running: 14 | 15 | ```sh 16 | $ code --install-extension .vsix 17 | ``` 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2020-2025 Buf Technologies, Inc. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := package 2 | 3 | .PHONY: package 4 | package: 5 | npm run package 6 | 7 | .PHONY: install 8 | install: 9 | npm install 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Buf for Visual Studio Code 2 | 3 | The [VS Code Buf extension](https://marketplace.visualstudio.com/items?itemName=bufbuild.vscode-buf) provides rich support for [Protocol Buffers](https://protobuf.dev/) using the [Buf](https://buf.build/) development environment. 4 | 5 | ## Requirements 6 | 7 | - Visual Studio Code 8 | - A [Buf](https://buf.build/) configuration environment. 9 | 10 | ## Getting Started 11 | 12 | The Buf CLI is the ultimate tool for modern, fast, and efficient Protobuf API management. Getting started is simple! 13 | 14 | 1. Install the [VS Code Buf extension](https://marketplace.visualstudio.com/items?itemName=bufbuild.vscode-buf). 15 | 2. Create a [buf.yaml](https://buf.build/docs/cli/#default-configuration) in the root of your workspace. 16 | 3. Open any Protobuf file to automatically activate the extension. A Buf status bar appears in the bottom right corner of the window. 17 | 4. The extension depends on the [Buf CLI](https://buf.build/docs/cli/). If a Buf binary is not found in your path, the extension will attempt to install the latest release from Github. 18 | 19 | ## Features 20 | 21 | - **Code navigation** - Jump to or peek at a symbol's declaration. 22 | - **Syntax highlighting** - Protobuf specific color and styling of code. 23 | - **Code editing** - Support for formatting and linting. 24 | - **Buf command support** - Execution of Buf CLI commands e.g. [Buf Generate](https://buf.build/docs/generate/tutorial/). 25 | 26 | ## Extension Settings 27 | 28 | This extension contributes the following settings: 29 | 30 | ### buf.commandLine.path 31 | 32 | Default: `null` 33 | 34 | The path to a specific install of Buf to use. Relative paths are supported and are relative to the workspace root. 35 | 36 | > If not set and `buf.commandLine.version` is also not set, the extension will attempt to find a os-specific Buf binary on the path. 37 | 38 | ### buf.commandLine.version 39 | 40 | Default: `null` 41 | 42 | Specific version (git tag e.g. 'v1.53.0') of Buf release to download and install. 43 | 44 | ### buf.restartAfterCrash 45 | 46 | Default: `true` 47 | 48 | Automatically restart Buf (up to 4 times) if it crashes. 49 | 50 | ### buf.checkUpdates 51 | 52 | Default: `true` 53 | 54 | Check for language server updates on startup. 55 | 56 | ### buf.enableHover 57 | 58 | Default: `true` 59 | 60 | Enable hover features provided by the language server. 61 | 62 | ### buf.enable 63 | 64 | Default: `true` 65 | 66 | Enable Buf language server features. 67 | 68 | ### buf.debug 69 | 70 | Default: `false` 71 | 72 | Enable debug mode. 73 | 74 | ### buf.log-format 75 | 76 | Default: `text` 77 | 78 | Buf language server log format. 79 | 80 | ### buf.checks.breaking.againstStrategy 81 | 82 | Default: `git` 83 | 84 | The strategy to use when checking breaking changes against a specific reference. 85 | 86 | ### buf.checks.breaking.againstGitRef 87 | 88 | Default: `refs/remotes/origin/HEAD` 89 | 90 | The Git reference to check breaking changes against. 91 | 92 | ## Changelog 93 | 94 | - v0.8.0 95 | - Improve overall editor functionality, using Buf Language Server. 96 | - v0.7.2 97 | - Fix issue with `buf` commands returning an unexpected error on exec. 98 | - v0.7.1 99 | - Fix "undefined" error when running `buf` on the extension. 100 | - v0.7.0 101 | - Output errors to "Buf" channel in the VSCode output console. 102 | - v0.6.2 103 | - Reintroduce relative binary path support. 104 | - v0.6.1 105 | - Revert relative binary path support. 106 | - v0.6.0 107 | - Support relative binary path. 108 | - v0.5.3 109 | - Add syntax highlighting for `.proto` files. 110 | - v0.5.2 111 | - Fix lint highlighting issue outside of the current file. 112 | - v0.5.1 113 | - Fix an issue with setting buf format as the default formatter for proto3 files. 114 | - v0.5.0 115 | - Add formatting through using buf format. Defaults to format on save. 116 | - v0.4.0 117 | - Use single file reference to resolve lint file from any path 118 | - v0.3.1 119 | - Accept v1.0.0-rc1 in version parser 120 | - v0.3.0 121 | - Change `--version` to read from both `stdout` and `stderr` 122 | - v0.2.0 123 | - Update minimum required version to v0.34.0 124 | - v0.1.3 125 | - Update logo 126 | - v0.1.0 127 | - Add version check and download link 128 | - v0.0.3 129 | - Fix missing generation command 130 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from "@eslint/js"; 2 | import tseslint from "typescript-eslint"; 3 | import eslintPluginUnicorn from "eslint-plugin-unicorn"; 4 | import { defineConfig, globalIgnores } from "eslint/config"; 5 | import globals from "globals"; 6 | 7 | export default defineConfig( 8 | globalIgnores([ 9 | ".vscode-test", 10 | ".vscode-dist", 11 | "node_modules", 12 | "out", 13 | "test-workspaces", 14 | ]), 15 | tseslint.config({ 16 | files: ["scripts/**/*.mjs"], 17 | languageOptions: { 18 | globals: globals.node, 19 | }, 20 | }), 21 | tseslint.config({ 22 | extends: [eslint.configs.recommended, ...tseslint.configs.recommended], 23 | plugins: { 24 | unicorn: eslintPluginUnicorn, 25 | }, 26 | rules: { 27 | "@typescript-eslint/no-unused-vars": [ 28 | "error", 29 | { 30 | argsIgnorePattern: "^_", 31 | varsIgnorePattern: "^_", 32 | caughtErrorsIgnorePattern: "^_", 33 | }, 34 | ], 35 | "@typescript-eslint/naming-convention": [ 36 | "error", 37 | { 38 | selector: "variable", 39 | format: ["camelCase", "PascalCase"], 40 | leadingUnderscore: "allow", 41 | }, 42 | { 43 | selector: "typeLike", 44 | format: ["PascalCase"], 45 | }, 46 | ], 47 | "unicorn/filename-case": [ 48 | "error", 49 | { 50 | case: "kebabCase", 51 | }, 52 | ], 53 | }, 54 | }) 55 | ); 56 | -------------------------------------------------------------------------------- /lint_errors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bufbuild/vscode-buf/a26061b1f718544c1433d38e71d02f51b504ecb9/lint_errors.png -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bufbuild/vscode-buf/a26061b1f718544c1433d38e71d02f51b504ecb9/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vscode-buf", 3 | "displayName": "Buf", 4 | "description": "Visual Studio Code support for Buf", 5 | "version": "0.8.0", 6 | "icon": "logo.png", 7 | "publisher": "bufbuild", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/bufbuild/vscode-buf.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/bufbuild/vscode-buf/issues" 14 | }, 15 | "license": "Apache-2.0", 16 | "licenses": [ 17 | { 18 | "type": "Apache-2.0", 19 | "url": "http://www.apache.org/licenses/LICENSE-2.0" 20 | } 21 | ], 22 | "engines": { 23 | "vscode": "^1.83.1" 24 | }, 25 | "categories": [ 26 | "Formatters", 27 | "Linters" 28 | ], 29 | "keywords": [ 30 | "proto", 31 | "proto3", 32 | "protobuf", 33 | "protocol buffers", 34 | "buf", 35 | "bufbuild", 36 | "lint", 37 | "format" 38 | ], 39 | "activationEvents": [ 40 | "workspaceContains:**/*.proto", 41 | "workspaceContains:**/buf.yaml", 42 | "workspaceContains:**/buf.lock", 43 | "workspaceContains:**/buf.mod", 44 | "workspaceContains:**/buf.work", 45 | "workspaceContains:**/buf.gen", 46 | "workspaceContains:**/buf.gen.yaml", 47 | "workspaceContains:**/buf.work.yaml" 48 | ], 49 | "contributes": { 50 | "configuration": { 51 | "title": "Buf", 52 | "properties": { 53 | "buf.commandLine.path": { 54 | "type": "string", 55 | "description": "The path to a specific install of Buf to use. Relative paths are supported and are relative to the workspace root." 56 | }, 57 | "buf.commandLine.version": { 58 | "type": "string", 59 | "description": "Specific version (git tag e.g. 'v1.53.0') of Buf release to download and install." 60 | }, 61 | "buf.restartAfterCrash": { 62 | "type": "boolean", 63 | "default": true, 64 | "description": "Automatically restart Buf (up to 4 times) if it crashes." 65 | }, 66 | "buf.checkUpdates": { 67 | "type": "boolean", 68 | "default": true, 69 | "description": "Check for language server updates on startup." 70 | }, 71 | "buf.enableHover": { 72 | "type": "boolean", 73 | "default": true, 74 | "description": "Enable hover features provided by the language server." 75 | }, 76 | "buf.enable": { 77 | "type": "boolean", 78 | "default": true, 79 | "description": "Enable Buf language server features." 80 | }, 81 | "buf.debug": { 82 | "type": "boolean", 83 | "default": false, 84 | "description": "Enable debug mode." 85 | }, 86 | "buf.log-format": { 87 | "type": [ 88 | "string", 89 | "null" 90 | ], 91 | "enum": [ 92 | "text", 93 | "color", 94 | "json" 95 | ], 96 | "default": "text", 97 | "description": "Buf language server log format." 98 | }, 99 | "buf.checks.breaking.againstStrategy": { 100 | "type": "string", 101 | "enum": [ 102 | "disk", 103 | "git" 104 | ], 105 | "default": "git", 106 | "description": "The strategy to use when checking breaking changes against a specific reference." 107 | }, 108 | "buf.checks.breaking.againstGitRef": { 109 | "type": "string", 110 | "default": "refs/remotes/origin/HEAD", 111 | "description": "The Git reference to check breaking changes against." 112 | } 113 | } 114 | }, 115 | "configurationDefaults": { 116 | "[proto]": { 117 | "editor.formatOnSave": true 118 | } 119 | }, 120 | "languages": [ 121 | { 122 | "id": "yaml", 123 | "filenames": [ 124 | "buf.lock", 125 | "buf.mod", 126 | "buf.work", 127 | "buf.gen" 128 | ] 129 | }, 130 | { 131 | "id": "proto", 132 | "extensions": [ 133 | ".proto" 134 | ], 135 | "aliases": [ 136 | "Protocol Buffers", 137 | "Protobuf" 138 | ], 139 | "configuration": "./protobuf-language-configuration.json" 140 | } 141 | ], 142 | "grammars": [ 143 | { 144 | "language": "proto", 145 | "scopeName": "source.proto", 146 | "path": "./syntaxes/proto.tmLanguage.json" 147 | } 148 | ], 149 | "commands": [ 150 | { 151 | "command": "buf.generate", 152 | "category": "Buf", 153 | "title": "$(run) Generate", 154 | "description": "Run Buf to generate code with protoc plugins." 155 | }, 156 | { 157 | "command": "buf.showOutput", 158 | "category": "Buf", 159 | "title": "$(output) Show Buf Output" 160 | }, 161 | { 162 | "command": "buf.install", 163 | "category": "Buf", 164 | "title": "$(cloud-download) Install CLI", 165 | "description": "Install the Buf CLI from GitHub releases." 166 | }, 167 | { 168 | "command": "buf.update", 169 | "category": "Buf", 170 | "title": "$(arrow-swap) Update CLI", 171 | "description": "Check for updates and install the latest version of the Buf CLI." 172 | }, 173 | { 174 | "command": "buf.restart", 175 | "category": "Buf", 176 | "title": "$(debug-restart) Restart Buf Language Server" 177 | } 178 | ] 179 | }, 180 | "main": "./out/src/extension.js", 181 | "scripts": { 182 | "vscode:prepublish": "npm run compile", 183 | "clean": "rm -rf ./dist/* && rm -rf ./out/* && rm -rf ./bin/* && rm *.vsix", 184 | "compile": "tsc -p ./", 185 | "watch": "tsc -watch -p ./", 186 | "pretest": "npm run compile && npm run prepworkspaces", 187 | "prepworkspaces": "(cd test-workspaces/npm-buf-workspace && npm install)", 188 | "lint": "eslint --max-warnings 0 .", 189 | "test": "npm run test:unit && npm run test:e2e", 190 | "test:unit": "vscode-test", 191 | "test:e2e": "playwright test", 192 | "test:e2e:debug": "playwright test --debug --workers=1 --retries=0 --reporter=list", 193 | "package": "vsce package", 194 | "format": "prettier --write '**/*.{json,js,jsx,ts,tsx,css,yaml,yml,mjs,mts,md}' --log-level error" 195 | }, 196 | "devDependencies": { 197 | "@eslint/js": "^9.23.0", 198 | "@playwright/test": "^1.51.1", 199 | "@types/mocha": "^10.0.6", 200 | "@types/node": "^20.x", 201 | "@types/proxyquire": "^1.3.31", 202 | "@types/semver": "^7.7.0", 203 | "@types/sinon": "^17.0.4", 204 | "@types/vscode": "^1.83.1", 205 | "@types/which": "^3.0.4", 206 | "@typescript-eslint/eslint-plugin": "^8.29.0", 207 | "@typescript-eslint/parser": "^8.29.0", 208 | "@vscode/test-cli": "0.0.4", 209 | "@vscode/test-electron": "^2.5.2", 210 | "@vscode/vsce": "^2.15.0", 211 | "cross-env": "^7.0.3", 212 | "eslint": "^9.23.0", 213 | "eslint-plugin-unicorn": "^58.0.0", 214 | "globals": "^16.0.0", 215 | "mocha": "^10.2.0", 216 | "playwright": "^1.51.1", 217 | "prettier": "^3.5.3", 218 | "proxyquire": "^2.1.3", 219 | "sinon": "^19.0.4", 220 | "typescript": "^5.7.2", 221 | "typescript-eslint": "^8.29.0" 222 | }, 223 | "dependencies": { 224 | "rimraf": "^6.0.1", 225 | "semver": "^7.7.1", 226 | "vscode-languageclient": "^9.0.1", 227 | "which": "^5.0.0" 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@playwright/test"; 2 | import { TestOptions } from "./test/e2e/base-test"; 3 | 4 | /** 5 | * See https://playwright.dev/docs/test-configuration 6 | */ 7 | export default defineConfig({ 8 | // Directory where tests are located 9 | testDir: "./test/e2e", 10 | fullyParallel: true, 11 | forbidOnly: !!process.env.CI, 12 | workers: process.env.CI ? 1 : undefined, 13 | reporter: process.env.CI ? "html" : "list", 14 | timeout: 120_000_000, 15 | expect: { 16 | timeout: 40_000, 17 | }, 18 | globalSetup: "./test/e2e/global-setup.ts", 19 | projects: [ 20 | { 21 | name: "VSCode insiders", 22 | use: { 23 | vsCodeVersion: "insiders", 24 | }, 25 | }, 26 | ], 27 | }); 28 | -------------------------------------------------------------------------------- /protobuf-language-configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "comments": { 3 | "lineComment": "//", 4 | "blockComment": ["/*", "*/"] 5 | }, 6 | "brackets": [ 7 | ["{", "}"], 8 | ["[", "]"], 9 | ["(", ")"], 10 | ["<", ">"] 11 | ], 12 | "autoClosingPairs": [ 13 | ["{", "}"], 14 | ["[", "]"], 15 | ["(", ")"], 16 | ["<", ">"], 17 | ["\"", "\""], 18 | ["'", "'"] 19 | ], 20 | "surroundingPairs": [ 21 | ["{", "}"], 22 | ["[", "]"], 23 | ["(", ")"], 24 | ["<", ">"], 25 | ["\"", "\""], 26 | ["'", "'"] 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /scripts/gh-diffcheck.mjs: -------------------------------------------------------------------------------- 1 | import { execSync } from "node:child_process"; 2 | 3 | if (gitUncommitted()) { 4 | process.stdout.write( 5 | "::error::Uncommitted changes found. Please make sure this branch is up to date, and run the command locally (for example `npx turbo format`). " + 6 | "Verify the changes are what you want and commit them.\n" 7 | ); 8 | execSync("git --no-pager diff", { 9 | stdio: "inherit", 10 | }); 11 | process.exit(1); 12 | } 13 | 14 | /** 15 | * @returns {boolean} 16 | */ 17 | function gitUncommitted() { 18 | const out = execSync("git status --porcelain", { 19 | encoding: "utf-8", 20 | }); 21 | return out.trim().length > 0; 22 | } 23 | -------------------------------------------------------------------------------- /src/commands/buf-generate.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | import { Command, CommandType } from "./command"; 4 | import { unwrapError } from "../errors"; 5 | import { log } from "../log"; 6 | import { execFile } from "../util"; 7 | 8 | export const bufGenerate = new Command( 9 | "buf.generate", 10 | CommandType.COMMAND_BUF, 11 | (_, bufCtx) => { 12 | return async () => { 13 | if (!bufCtx.buf) { 14 | log.error("Buf is not installed. Please install Buf."); 15 | return; 16 | } 17 | 18 | try { 19 | const { stdout, stderr } = await execFile( 20 | bufCtx.buf?.path, 21 | ["generate"], 22 | { 23 | cwd: vscode.workspace.rootPath, 24 | } 25 | ); 26 | 27 | if (stderr) { 28 | log.error(`Error generating buf: ${stderr}`); 29 | return; 30 | } 31 | 32 | log.info(stdout); 33 | } catch (e) { 34 | log.error(`Error generating buf: ${unwrapError(e)}`); 35 | } 36 | }; 37 | } 38 | ); 39 | -------------------------------------------------------------------------------- /src/commands/command.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import * as vscode from "vscode"; 3 | 4 | import { BufContext } from "../context"; 5 | 6 | export type CommandCallback = (...args: any) => Promise | T; 7 | 8 | export type CommandFactory = ( 9 | ctx: vscode.ExtensionContext, 10 | bufCtx: BufContext 11 | ) => CommandCallback; 12 | 13 | // Various command types that can be registered with the command palette. Types 14 | // control grouping in the command palette. 15 | export enum CommandType { 16 | // Group of commands that run buf e.g. `buf generate`. 17 | COMMAND_BUF, 18 | 19 | // Group of commands that interact with the extension. 20 | COMMAND_EXTENSION, 21 | 22 | // Group of commands that relate to setting buf cli up e.g. install / update. 23 | COMMAND_SETUP, 24 | 25 | // Internal commands. Note: these are not registered in the command palette 26 | COMMAND_INTERNAL, 27 | } 28 | 29 | export class Command { 30 | constructor( 31 | public readonly command: string, 32 | public readonly type: CommandType, 33 | public readonly factory: CommandFactory 34 | ) {} 35 | 36 | register(ctx: vscode.ExtensionContext, bufCtx: BufContext) { 37 | ctx.subscriptions.push( 38 | vscode.commands.registerCommand( 39 | this.command, 40 | this.wrapCommand(ctx, bufCtx) 41 | ) 42 | ); 43 | } 44 | 45 | execute(): Thenable { 46 | return vscode.commands.executeCommand(this.command); 47 | } 48 | 49 | private wrapCommand( 50 | ctx: vscode.ExtensionContext, 51 | bufCtx: BufContext 52 | ): CommandCallback { 53 | const fn = this.factory(ctx, bufCtx); 54 | 55 | return async (...args: any[]) => { 56 | let result: Promise | T; 57 | 58 | bufCtx.busy = true; 59 | try { 60 | result = fn(...args); 61 | 62 | if (result instanceof Promise) { 63 | result = await result; 64 | } 65 | } finally { 66 | bufCtx.busy = false; 67 | } 68 | 69 | return result; 70 | }; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/commands/find-buf.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import * as vscode from "vscode"; 3 | import * as config from "../config"; 4 | import * as github from "../github"; 5 | 6 | import which from "which"; 7 | import { Command, CommandType } from "./command"; 8 | import { bufFilename } from "../const"; 9 | import { unwrapError } from "../errors"; 10 | import { log } from "../log"; 11 | import { BufVersion } from "../version"; 12 | 13 | // This command is used to find the buf binary and set it in the context. 14 | // Order of precedence: 15 | // 1. Allow the user to set the buf path in the config e.g. 'buf.commandLine.path: /usr/local/bin/buf' 16 | // 2. Allow the extension to manage the buf version for the user and always update to the latest e.g. 'buf.commandLine.version: latest' 17 | // 3. If the user has not set a path or version, we will look for buf in the OS path. 18 | export const findBuf = new Command( 19 | "buf.findBinary", 20 | CommandType.COMMAND_INTERNAL, 21 | (ctx, bufCtx) => { 22 | return async () => { 23 | let configPath = config.get("commandLine.path"); 24 | const configVersion = config.get("commandLine.version"); 25 | 26 | if (configPath) { 27 | if (!path.isAbsolute(configPath)) { 28 | configPath = path.join( 29 | vscode.workspace.rootPath || process.cwd(), //todo: fix me in the future when we want to support multi-root workspaces 30 | configPath 31 | ); 32 | } 33 | 34 | if (configVersion) { 35 | log.warn( 36 | "Both 'buf.commandLine.path' and 'buf.commandLine.version' are set. Using 'buf.commandLine.path'." 37 | ); 38 | } 39 | 40 | try { 41 | log.info(`Buf path set to '${configPath}'.`); 42 | bufCtx.buf = await BufVersion.fromPath(configPath); 43 | 44 | if (bufCtx.buf) { 45 | log.info( 46 | `Using '${bufCtx.buf.path}', version: ${bufCtx.buf.version}.` 47 | ); 48 | } 49 | } catch (e) { 50 | log.error( 51 | `Error loading buf from path '${configPath}': ${unwrapError(e)}` 52 | ); 53 | bufCtx.buf = undefined; 54 | } 55 | 56 | return; 57 | } 58 | 59 | if (configVersion) { 60 | let version = configVersion; 61 | 62 | // Get the latest version from GitHub if the user has set it to "latest". 63 | if (configVersion === "latest") { 64 | const latestRelease = await github.getRelease(); 65 | version = latestRelease.tag_name; 66 | } 67 | 68 | try { 69 | log.info( 70 | `Buf version set to '${configVersion}'. Looking for '${version}' in extension storage...` 71 | ); 72 | bufCtx.buf = await getBufInStorage(ctx, version); 73 | 74 | if (bufCtx.buf) { 75 | log.info( 76 | `Using '${bufCtx.buf.path}', version: ${bufCtx.buf.version}.` 77 | ); 78 | } 79 | } catch (e) { 80 | log.error(`Error finding an installed buf: ${unwrapError(e)}`); 81 | bufCtx.buf = undefined; 82 | } 83 | 84 | return; 85 | } 86 | 87 | // If we didn't get a valid buf binary from config... 88 | log.info("Looking for buf on the path..."); 89 | 90 | // If we didn't find it in storage... 91 | try { 92 | bufCtx.buf = await findBufInPath(); 93 | 94 | if (bufCtx.buf) { 95 | log.info( 96 | `Using '${bufCtx.buf.path}', version: ${bufCtx.buf.version}.` 97 | ); 98 | return; 99 | } 100 | } catch (e) { 101 | log.error(`Error finding an installed buf: ${unwrapError(e)}`); 102 | } 103 | 104 | // If we made it to this point, we found nothing, throw an error. 105 | log.error( 106 | "Buf is not installed or no valid binary could be found. Please install Buf." 107 | ); 108 | }; 109 | } 110 | ); 111 | 112 | const findBufInPath = async (): Promise => { 113 | const bufPath = await which(bufFilename, { nothrow: true }); 114 | 115 | if (bufPath) { 116 | return BufVersion.fromPath(bufPath); 117 | } 118 | 119 | return undefined; 120 | }; 121 | 122 | const getBufInStorage = async ( 123 | ctx: vscode.ExtensionContext, 124 | version: string 125 | ): Promise => { 126 | const bufPath = path.join(ctx.globalStorageUri.fsPath, version, bufFilename); 127 | 128 | // Check if the buf binary exists in the storage path. 129 | try { 130 | await vscode.workspace.fs.stat(vscode.Uri.file(bufPath)); 131 | } catch { 132 | return undefined; 133 | } 134 | 135 | return await BufVersion.fromPath(bufPath); 136 | }; 137 | -------------------------------------------------------------------------------- /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | export { bufGenerate } from "./buf-generate"; 2 | export { findBuf } from "./find-buf"; 3 | export { installBuf } from "./install-buf"; 4 | export { registerAllCommands } from "./register-all-commands"; 5 | export { restartBuf } from "./restart-buf"; 6 | export { showCommands } from "./show-commands"; 7 | export { showOutput } from "./show-output"; 8 | export { stopBuf } from "./stop-buf"; 9 | export { updateBuf } from "./update-buf"; 10 | -------------------------------------------------------------------------------- /src/commands/install-buf.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | import * as vscode from "vscode"; 4 | import * as config from "../config"; 5 | import * as github from "../github"; 6 | 7 | import { restartBuf, updateBuf } from "."; 8 | import { bufFilename, installURL } from "../const"; 9 | import { log } from "../log"; 10 | import { showHelp } from "../ui"; 11 | import { download } from "../util"; 12 | import { BufVersion } from "../version"; 13 | import { Command, CommandType } from "./command"; 14 | 15 | export const installBuf = new Command( 16 | "buf.install", 17 | CommandType.COMMAND_SETUP, 18 | (ctx, bufCtx) => { 19 | return async () => { 20 | // If 'buf.commandLine.path' is set explicitly, we don't want to do anything. 21 | if (config.get("commandLine.path")) { 22 | vscode.window.showErrorMessage( 23 | "'buf.commandLine.path' is explicitly set. Please remove it to allow the extension to manage Buf." 24 | ); 25 | return; 26 | } 27 | 28 | if (bufCtx.buf) { 29 | // Assume the user wants to attempt an upgrade... 30 | await updateBuf.execute(); 31 | return; 32 | } 33 | 34 | const abort = new AbortController(); 35 | try { 36 | const version = config.get("commandLine.version") || "latest"; 37 | log.info(`Checking github releases for '${version}' release...`); 38 | const release = await github.getRelease( 39 | version === "latest" ? undefined : version 40 | ); 41 | const asset = await github.findAsset(release); 42 | 43 | const bufPath = await install(ctx, release, asset, abort); 44 | 45 | bufCtx.buf = await BufVersion.fromPath(bufPath); 46 | vscode.window.showInformationMessage( 47 | `Buf ${release.name} is now installed.` 48 | ); 49 | 50 | await restartBuf.execute(); 51 | } catch (e) { 52 | if (!abort.signal.aborted) { 53 | log.info(`Failed to install buf: ${e}`); 54 | 55 | const message = `Failed to install Buf cli: ${e}\nYou may want to install it manually.`; 56 | showHelp(message, installURL); 57 | } 58 | } 59 | }; 60 | } 61 | ); 62 | 63 | export const install = async ( 64 | ctx: vscode.ExtensionContext, 65 | release: github.Release, 66 | asset: github.Asset, 67 | abort: AbortController 68 | ): Promise => { 69 | const downloadDir = path.join(ctx.globalStorageUri.fsPath, release.tag_name); 70 | await fs.promises.mkdir(downloadDir, { recursive: true }); 71 | 72 | const downloadBin = path.join(downloadDir, bufFilename); 73 | 74 | try { 75 | // Check the binary exists. 76 | await fs.promises.access(downloadBin); 77 | 78 | // Check we can execute the binary and determine its version. 79 | await BufVersion.fromPath(downloadBin); 80 | 81 | // If we made it to this point, the binary is valid, reuse it. 82 | return downloadBin; 83 | } catch { 84 | // Ignore errors, we will download it. 85 | } 86 | 87 | // If we've fallen out here.. lets proceed to download. 88 | log.info(`Downloading ${asset.name} to ${downloadBin}...`); 89 | 90 | await download(asset.browser_download_url, downloadBin, abort); 91 | 92 | await fs.promises.chmod(downloadBin, 0o755); 93 | 94 | return downloadBin; 95 | }; 96 | -------------------------------------------------------------------------------- /src/commands/register-all-commands.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | import { BufContext } from "../context"; 4 | import { Command } from "./command"; 5 | 6 | import { bufGenerate } from "./buf-generate"; 7 | import { findBuf } from "./find-buf"; 8 | import { installBuf } from "./install-buf"; 9 | import { restartBuf } from "./restart-buf"; 10 | import { showCommands } from "./show-commands"; 11 | import { showOutput } from "./show-output"; 12 | import { stopBuf } from "./stop-buf"; 13 | import { updateBuf } from "./update-buf"; 14 | 15 | const commands = [ 16 | bufGenerate, 17 | findBuf, 18 | installBuf, 19 | restartBuf, 20 | showCommands, 21 | showOutput, 22 | stopBuf, 23 | updateBuf, 24 | ]; 25 | 26 | const commandMap = new Map(); 27 | commands.forEach((command) => { 28 | commandMap.set(command.command, command); 29 | }); 30 | 31 | export const registerAllCommands = ( 32 | ctx: vscode.ExtensionContext, 33 | bufCtx: BufContext 34 | ) => { 35 | commands.forEach((command) => { 36 | command.register(ctx, bufCtx); 37 | }); 38 | }; 39 | 40 | export const findCommand = (command: string): Command | undefined => { 41 | return commandMap.get(command); 42 | }; 43 | -------------------------------------------------------------------------------- /src/commands/restart-buf.ts: -------------------------------------------------------------------------------- 1 | import * as os from "os"; 2 | import * as vscode from "vscode"; 3 | import * as lsp from "vscode-languageclient/node"; 4 | import * as config from "../config"; 5 | 6 | import { stopBuf } from "."; 7 | import { protoDocumentSelector } from "../const"; 8 | import { BufContext, ServerStatus } from "../context"; 9 | import { log } from "../log"; 10 | import { Command, CommandType } from "./command"; 11 | 12 | export const restartBuf = new Command( 13 | "buf.restart", 14 | CommandType.COMMAND_EXTENSION, 15 | (ctx, bufCtx) => { 16 | const outputChannel = vscode.window.createOutputChannel("Buf (server)"); 17 | ctx.subscriptions.push(outputChannel); 18 | 19 | const callback = async () => { 20 | if (bufCtx.client) { 21 | await stopBuf.execute(); 22 | } 23 | 24 | if (!config.get("enable")) { 25 | bufCtx.status = ServerStatus.SERVER_DISABLED; 26 | log.warn("Buf is disabled. Enable it by setting `buf.enable` to true."); 27 | return; 28 | } 29 | 30 | if (!bufCtx.buf) { 31 | log.error("Buf is not installed. Please install Buf."); 32 | bufCtx.status = ServerStatus.SERVER_NOT_INSTALLED; 33 | return; 34 | } 35 | 36 | bufCtx.status = ServerStatus.SERVER_STARTING; 37 | 38 | log.info(`Starting Buf Language Server (${bufCtx.buf.version})...`); 39 | 40 | const buf: lsp.Executable = { 41 | command: bufCtx.buf.path, 42 | args: getBufArgs(), 43 | options: { 44 | cwd: vscode.workspace.rootPath || process.cwd(), //todo: How do we support across multiple workspace folders? 45 | }, 46 | }; 47 | 48 | if (os.platform() !== "win32") { 49 | buf.transport = lsp.TransportKind.pipe; 50 | } 51 | 52 | const serverOptions: lsp.ServerOptions = buf; 53 | 54 | const clientOptions: lsp.LanguageClientOptions = { 55 | documentSelector: protoDocumentSelector, 56 | diagnosticCollectionName: "bufc", 57 | outputChannel: outputChannel, 58 | revealOutputChannelOn: lsp.RevealOutputChannelOn.Never, 59 | middleware: { 60 | provideHover: async (document, position, token, next) => { 61 | if (!config.get("enableHover")) { 62 | return null; 63 | } 64 | return next(document, position, token); 65 | }, 66 | }, 67 | markdown: { 68 | supportHtml: true, 69 | }, 70 | }; 71 | 72 | bufCtx.client = new lsp.LanguageClient( 73 | "Buf Language Server", 74 | serverOptions, 75 | clientOptions 76 | ); 77 | bufCtx.client.clientOptions.errorHandler = createErrorHandler(bufCtx); 78 | 79 | await bufCtx.client.start(); 80 | 81 | bufCtx.status = ServerStatus.SERVER_RUNNING; 82 | 83 | log.info("Buf Language Server started."); 84 | }; 85 | 86 | return callback; 87 | } 88 | ); 89 | 90 | const getBufArgs = () => { 91 | const bufArgs = []; 92 | 93 | if (config.get("debug")) { 94 | bufArgs.push("--debug"); 95 | } 96 | 97 | const logFormat = config.get("log-format"); 98 | if (logFormat) { 99 | bufArgs.push("--log-format", logFormat); 100 | } 101 | 102 | bufArgs.push("beta", "lsp"); 103 | 104 | return bufArgs; 105 | }; 106 | 107 | const createErrorHandler = (bufCtx: BufContext): lsp.ErrorHandler => { 108 | if (!bufCtx.client) { 109 | throw new Error("Client is not initialized."); 110 | } 111 | 112 | const errorHandler = bufCtx.client.createDefaultErrorHandler( 113 | config.get("restartAfterCrash") ? /*default*/ 4 : 0 114 | ); 115 | 116 | return { 117 | error: (error, message, count) => { 118 | return errorHandler.error(error, message, count); 119 | }, 120 | closed: async () => { 121 | const result = await errorHandler.closed(); 122 | 123 | if (result.action === lsp.CloseAction.DoNotRestart) { 124 | bufCtx.status = ServerStatus.SERVER_ERRORED; 125 | log.error(`Language Server closed unexpectedly. Not restarting.`); 126 | } else { 127 | log.warn(`Language Server closed unexpectedly. Restarting...`); 128 | } 129 | 130 | return result; 131 | }, 132 | }; 133 | }; 134 | -------------------------------------------------------------------------------- /src/commands/show-commands.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import * as config from "../config"; 3 | 4 | import { extensionId } from "../const"; 5 | import { Command, CommandType } from "./command"; 6 | import { findCommand } from "./register-all-commands"; 7 | 8 | type BufQuickPickItem = vscode.QuickPickItem & { command?: Command }; 9 | 10 | export const showCommands = new Command( 11 | "buf.showCommands", 12 | CommandType.COMMAND_EXTENSION, 13 | () => { 14 | const quickPickList: BufQuickPickItem[] = []; 15 | 16 | const pkgJSON = vscode.extensions.getExtension(extensionId)?.packageJSON; 17 | if (pkgJSON.contributes && pkgJSON.contributes.commands) { 18 | const commands: { 19 | command: string; 20 | title: string; 21 | description: string; 22 | }[] = pkgJSON.contributes.commands; 23 | const bufCommands: BufQuickPickItem[] = []; 24 | const extCommands: BufQuickPickItem[] = []; 25 | const setupCommands: BufQuickPickItem[] = []; 26 | 27 | const configPathSet = !!config.get("commandLine.path"); 28 | 29 | for (const cmd of commands.sort((a, b) => 30 | a.command.localeCompare(b.command) 31 | )) { 32 | const extCmd = findCommand(cmd.command); 33 | 34 | if (!extCmd) { 35 | throw new Error(`Command ${cmd.command} not found!`); 36 | } 37 | 38 | if (extCmd.type === CommandType.COMMAND_INTERNAL) { 39 | throw new Error( 40 | `Command ${cmd.command} is an internal command and should not be shown!` 41 | ); 42 | } 43 | 44 | switch (extCmd.type) { 45 | case CommandType.COMMAND_BUF: 46 | bufCommands.push({ 47 | label: cmd.title, 48 | command: extCmd, 49 | detail: cmd.description, 50 | }); 51 | break; 52 | case CommandType.COMMAND_EXTENSION: 53 | extCommands.push({ 54 | label: cmd.title, 55 | command: extCmd, 56 | detail: cmd.description, 57 | }); 58 | break; 59 | case CommandType.COMMAND_SETUP: 60 | if (!configPathSet) { 61 | setupCommands.push({ 62 | label: cmd.title, 63 | command: extCmd, 64 | detail: cmd.description, 65 | }); 66 | } 67 | break; 68 | default: 69 | throw new Error(`Command ${cmd.title} has an unknown type!`); 70 | } 71 | } 72 | 73 | quickPickList.push({ 74 | kind: vscode.QuickPickItemKind.Separator, 75 | label: "Buf Commands", 76 | }); 77 | quickPickList.push(...bufCommands); 78 | quickPickList.push({ 79 | kind: vscode.QuickPickItemKind.Separator, 80 | label: "Extension Commands", 81 | }); 82 | quickPickList.push(...extCommands); 83 | quickPickList.push({ 84 | kind: vscode.QuickPickItemKind.Separator, 85 | label: "Setup Commands", 86 | }); 87 | quickPickList.push(...setupCommands); 88 | } 89 | 90 | return () => { 91 | vscode.window 92 | .showQuickPick(quickPickList) 93 | .then((cmd) => cmd?.command?.execute()); 94 | }; 95 | } 96 | ); 97 | -------------------------------------------------------------------------------- /src/commands/show-output.ts: -------------------------------------------------------------------------------- 1 | import { log } from "../log"; 2 | import { Command, CommandType } from "./command"; 3 | 4 | export const showOutput = new Command( 5 | "buf.showOutput", 6 | CommandType.COMMAND_EXTENSION, 7 | () => { 8 | return () => { 9 | log.show(); 10 | }; 11 | } 12 | ); 13 | -------------------------------------------------------------------------------- /src/commands/stop-buf.ts: -------------------------------------------------------------------------------- 1 | import * as config from "../config"; 2 | 3 | import { ServerStatus } from "../context"; 4 | import { log } from "../log"; 5 | import { Command, CommandType } from "./command"; 6 | 7 | export const stopBuf = new Command( 8 | "buf.stop", 9 | CommandType.COMMAND_EXTENSION, 10 | (_, bufCtx) => { 11 | return async () => { 12 | if (bufCtx.client) { 13 | log.info( 14 | `Request to stop language server (enabled: ${config.get("enable")})` 15 | ); 16 | await bufCtx.client.stop(); 17 | bufCtx.client = undefined; 18 | bufCtx.status = ServerStatus.SERVER_STOPPED; 19 | } 20 | }; 21 | } 22 | ); 23 | -------------------------------------------------------------------------------- /src/commands/update-buf.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import * as config from "../config"; 3 | import * as github from "../github"; 4 | 5 | import { restartBuf } from "."; 6 | import { ServerStatus } from "../context"; 7 | import { log } from "../log"; 8 | import { BufVersion, Upgrade } from "../version"; 9 | import { Command, CommandType } from "./command"; 10 | import { install } from "./install-buf"; 11 | 12 | export const updateBuf = new Command( 13 | "buf.update", 14 | CommandType.COMMAND_SETUP, 15 | (ctx, bufCtx) => { 16 | return async () => { 17 | // If 'buf.commandLine.path' is set explicitly, we don't want to do anything. 18 | if (config.get("commandLine.path")) { 19 | vscode.window.showErrorMessage( 20 | "'buf.commandLine.path' is explicitly set. Please remove it to allow the extension to manage Buf." 21 | ); 22 | return; 23 | } 24 | 25 | if (!bufCtx.buf) { 26 | log.error("Buf is not installed. Please install Buf."); 27 | bufCtx.status = ServerStatus.SERVER_NOT_INSTALLED; 28 | return; 29 | } 30 | 31 | const version = config.get("commandLine.version"); 32 | if (version && version !== "latest") { 33 | log.info(`Buf set to version '${version}'. Skipping update.`); 34 | return; 35 | } 36 | 37 | let release: github.Release; 38 | let asset: github.Asset; 39 | let upgrade: Upgrade; 40 | 41 | // Gather all the version information to see if there's an upgrade. 42 | try { 43 | log.info("Checking for buf update..."); 44 | release = await github.getRelease(); 45 | asset = await github.findAsset(release); // Ensure a binary for this platform. 46 | upgrade = await bufCtx.buf?.hasUpgrade(release); 47 | } catch (e) { 48 | log.info(`Failed to check for buf update: ${e}`); 49 | 50 | // We're not sure whether there's an upgrade: stay quiet unless asked. 51 | vscode.window.showErrorMessage(`Failed to check for buf update: ${e}`); 52 | return; 53 | } 54 | 55 | log.info( 56 | `buf update: available=${upgrade.upgrade} installed=${upgrade.old}` 57 | ); 58 | // Bail out if the new version is better or comparable. 59 | if (!upgrade.upgrade) { 60 | vscode.window.showInformationMessage( 61 | `buf is up-to-date (you have ${upgrade.old}, latest is ${upgrade.new})` 62 | ); 63 | return; 64 | } 65 | 66 | const abort = new AbortController(); 67 | const message = 68 | "An updated buf cli is available.\n " + 69 | `Would you like to upgrade to cli ${upgrade.new}? ` + 70 | `(from ${upgrade.old})`; 71 | const update = `Install cli ${upgrade.new}`; 72 | const dontCheck = "Don't ask again"; 73 | 74 | const response = await vscode.window.showInformationMessage( 75 | message, 76 | update, 77 | dontCheck 78 | ); 79 | if (response === update) { 80 | const bufPath = await install(ctx, release, asset, abort); 81 | vscode.window.showInformationMessage( 82 | `Buf ${release.name} is now installed.` 83 | ); 84 | 85 | bufCtx.buf = await BufVersion.fromPath(bufPath); 86 | await restartBuf.execute(); 87 | } else if (response === dontCheck) { 88 | config.update("checkUpdates", false, vscode.ConfigurationTarget.Global); 89 | } 90 | }; 91 | } 92 | ); 93 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import * as vscode from "vscode"; 3 | 4 | import { homedir } from "os"; 5 | 6 | // Gets the config value `buf.`. Applies ${variable} substitutions. 7 | export function get(key: string): T { 8 | return substitute(vscode.workspace.getConfiguration("buf").get(key)!); 9 | } 10 | 11 | // Sets the config value `buf.`. Does not apply substitutions. 12 | export function update( 13 | key: string, 14 | value: T, 15 | target?: vscode.ConfigurationTarget 16 | ) { 17 | return vscode.workspace.getConfiguration("buf").update(key, value, target); 18 | } 19 | 20 | // Traverse a JSON value, replacing placeholders in all strings. 21 | function substitute(val: T): T { 22 | if (typeof val === "string") { 23 | val = val.replace(/\$\{(.*?)\}/g, (match, name) => { 24 | // If there's no replacement available, keep the placeholder. 25 | return replacement(name) ?? match; 26 | }) as unknown as T; 27 | } else if (Array.isArray(val)) { 28 | val = val.map((x) => substitute(x)) as unknown as T; 29 | } else if (typeof val === "object" && val !== null) { 30 | // Substitute values but not keys, so we don't deal with collisions. 31 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 32 | const result = {} as { [k: string]: any }; 33 | for (const [k, v] of Object.entries(val)) { 34 | result[k] = substitute(v); 35 | } 36 | val = result as T; 37 | } 38 | return val; 39 | } 40 | 41 | // Subset of substitution variables that are most likely to be useful. 42 | // https://code.visualstudio.com/docs/editor/variables-reference 43 | function replacement(name: string): string | undefined { 44 | if (name === "userHome") { 45 | return homedir(); 46 | } 47 | if ( 48 | name === "workspaceRoot" || 49 | name === "workspaceFolder" || 50 | name === "cwd" 51 | ) { 52 | if (vscode.workspace.rootPath !== undefined) { 53 | return vscode.workspace.rootPath; 54 | } 55 | if (vscode.window.activeTextEditor !== undefined) { 56 | return path.dirname(vscode.window.activeTextEditor.document.uri.fsPath); 57 | } 58 | return process.cwd(); 59 | } 60 | if ( 61 | name === "workspaceFolderBasename" && 62 | vscode.workspace.rootPath !== undefined 63 | ) { 64 | return path.basename(vscode.workspace.rootPath); 65 | } 66 | const envPrefix = "env:"; 67 | if (name.startsWith(envPrefix)) { 68 | return process.env[name.substr(envPrefix.length)] ?? ""; 69 | } 70 | const configPrefix = "config:"; 71 | if (name.startsWith(configPrefix)) { 72 | const config = vscode.workspace 73 | .getConfiguration() 74 | .get(name.substr(configPrefix.length)); 75 | return typeof config === "string" ? config : undefined; 76 | } 77 | 78 | return undefined; 79 | } 80 | -------------------------------------------------------------------------------- /src/const.ts: -------------------------------------------------------------------------------- 1 | import * as os from "os"; 2 | import * as vscode from "vscode"; 3 | 4 | export const extensionId = "bufbuild.vscode-buf"; 5 | 6 | export const bufFilename = os.platform() === "win32" ? "buf.exe" : "buf"; 7 | 8 | export const minBufVersion = "v1.43.0"; 9 | 10 | export const githubReleaseURL = 11 | "https://api.github.com/repos/bufbuild/buf/releases/"; 12 | export const installURL = "https://buf.build/docs/cli/installation/"; 13 | 14 | export const protoDocumentSelector = [{ scheme: "file", language: "proto" }]; 15 | 16 | export const bufDocumentSelector = [ 17 | { language: "buf", scheme: "file", pattern: "**/buf.yaml" }, 18 | ]; 19 | 20 | export const isBufDocument = (document: vscode.TextDocument) => { 21 | return vscode.languages.match(bufDocumentSelector, document); 22 | }; 23 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import * as lsp from "vscode-languageclient/node"; 3 | import * as version from "./version"; 4 | 5 | export type BufFile = { 6 | path: string; 7 | module: string; 8 | }; 9 | 10 | export enum ServerStatus { 11 | SERVER_DISABLED, 12 | SERVER_STARTING, 13 | SERVER_RUNNING, 14 | SERVER_STOPPED, 15 | SERVER_ERRORED, 16 | SERVER_NOT_INSTALLED, 17 | } 18 | 19 | export class BufContext { 20 | public client?: lsp.LanguageClient; 21 | public bufFiles: Map = new Map(); 22 | 23 | private _busy: boolean = false; 24 | private _buf?: version.BufVersion; 25 | private _status: ServerStatus = ServerStatus.SERVER_STOPPED; 26 | 27 | private onDidChangeContextEmitter = new vscode.EventEmitter(); 28 | 29 | public set status(value: ServerStatus) { 30 | if (this._status !== value) { 31 | this._status = value; 32 | this.onDidChangeContextEmitter.fire(); 33 | } 34 | } 35 | 36 | public get status(): ServerStatus { 37 | return this._status; 38 | } 39 | 40 | public set busy(value: boolean) { 41 | this._busy = value; 42 | this.onDidChangeContextEmitter.fire(); 43 | } 44 | 45 | public get busy(): boolean { 46 | return this._busy; 47 | } 48 | 49 | public set buf(value: version.BufVersion | undefined) { 50 | if (this._buf !== value) { 51 | this._buf = value; 52 | if (!value) { 53 | this._status = ServerStatus.SERVER_NOT_INSTALLED; 54 | } 55 | this.onDidChangeContextEmitter.fire(); 56 | } 57 | } 58 | 59 | public get buf(): version.BufVersion | undefined { 60 | return this._buf; 61 | } 62 | 63 | public get onDidChangeContext(): vscode.Event { 64 | return this.onDidChangeContextEmitter.event; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | export const unwrapError = (err: unknown) => { 2 | return err instanceof Error ? err.message : String(err); 3 | }; 4 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import * as commands from "./commands"; 3 | import * as config from "./config"; 4 | import * as status from "./status"; 5 | 6 | import { BufContext, ServerStatus } from "./context"; 7 | import { log } from "./log"; 8 | 9 | const bufCtx = new BufContext(); 10 | 11 | export async function activate(ctx: vscode.ExtensionContext) { 12 | status.activate(ctx, bufCtx); 13 | 14 | commands.registerAllCommands(ctx, bufCtx); 15 | 16 | ctx.subscriptions.push( 17 | vscode.workspace.onDidChangeConfiguration(handleOnDidConfigChange) 18 | ); 19 | 20 | await commands.findBuf.execute(); 21 | 22 | if (!bufCtx.buf) { 23 | log.warn("No buf cli found. Installing buf..."); 24 | await commands.installBuf.execute(); 25 | } 26 | 27 | if (config.get("checkUpdates")) { 28 | // Check asynchronously for updates. 29 | commands.updateBuf.execute(); 30 | } 31 | 32 | // We may have already started running if we had to install buf, so don't try 33 | // and start if we're already running. 34 | if (bufCtx.buf && bufCtx.status !== ServerStatus.SERVER_RUNNING) { 35 | // Start the language server 36 | await commands.restartBuf.execute(); 37 | } 38 | } 39 | 40 | // Nothing to do for now 41 | export async function deactivate() { 42 | log.info("Deactivating extension."); 43 | 44 | await commands.stopBuf.execute(); 45 | 46 | status.disposeStatusBar(); 47 | } 48 | 49 | const handleOnDidConfigChange = async (e: vscode.ConfigurationChangeEvent) => { 50 | if (!e.affectsConfiguration("buf")) { 51 | return; 52 | } 53 | 54 | if ( 55 | e.affectsConfiguration("buf.commandLine.path") || 56 | e.affectsConfiguration("buf.commandLine.version") 57 | ) { 58 | await commands.findBuf.execute(); 59 | 60 | // If we don't have a buf cli after attempting a find, try to install one. 61 | if (!bufCtx.buf) { 62 | log.warn("No buf cli found. Installing buf..."); 63 | await commands.installBuf.execute(); 64 | } 65 | } 66 | 67 | commands.restartBuf.execute(); 68 | }; 69 | -------------------------------------------------------------------------------- /src/github.ts: -------------------------------------------------------------------------------- 1 | import * as os from "os"; 2 | import * as vscode from "vscode"; 3 | 4 | import { githubReleaseURL } from "./const"; 5 | 6 | export interface Release { 7 | name: string; 8 | tag_name: string; 9 | assets: Array; 10 | } 11 | export interface Asset { 12 | name: string; 13 | browser_download_url: string; 14 | } 15 | 16 | // Fetch the metadata for the latest stable release. 17 | export const getRelease = async (tag?: string): Promise => { 18 | const releaseUrl = `${githubReleaseURL}${tag ? `tags/${tag}` : "latest"}`; 19 | 20 | const timeoutController = new AbortController(); 21 | const timeout = setTimeout(() => { 22 | timeoutController.abort(); 23 | }, 5000); 24 | try { 25 | const authToken = await getAuthToken(); 26 | const headers = new Headers(); 27 | 28 | if (authToken) { 29 | headers.set("Authorization", `Bearer ${authToken}`); 30 | } 31 | 32 | const response = await fetch(releaseUrl, { 33 | signal: timeoutController.signal, 34 | headers: headers, 35 | }); 36 | if (!response.ok) { 37 | console.error(response.url, response.status, response.statusText); 38 | throw new Error( 39 | `Can't fetch release '${tag ? tag : "latest"}': ${response.statusText}` 40 | ); 41 | } 42 | return (await response.json()) as Release; 43 | } finally { 44 | clearTimeout(timeout); 45 | } 46 | }; 47 | 48 | // Determine which release asset should be installed for this machine. 49 | export const findAsset = async (release: Release): Promise => { 50 | const platforms: { [key: string]: string } = { 51 | darwin: "Darwin", 52 | linux: "Linux", 53 | win32: "Windows", 54 | }; 55 | 56 | const platform = platforms[os.platform()]; 57 | 58 | let arch = os.arch(); 59 | 60 | if (arch === "x86" || arch === "x64") { 61 | arch = "x86_64"; 62 | } else if (arch === "arm64" && platform === "Linux") { 63 | arch = "aarch64"; 64 | } 65 | 66 | let platformKey = `buf-${platform}-${arch}`; 67 | 68 | if (platform === "Windows") { 69 | platformKey += ".exe"; 70 | } 71 | 72 | const asset = release.assets.find((a) => a.name === platformKey); 73 | if (asset) { 74 | return asset; 75 | } 76 | 77 | throw new Error( 78 | `No buf ${release.name} binary available, looking for '${platformKey}'` 79 | ); 80 | }; 81 | 82 | const getAuthToken = async (): Promise => { 83 | try { 84 | const session = await vscode.authentication.getSession("github", [], { 85 | createIfNone: false, 86 | }); 87 | if (session) { 88 | return session.accessToken; 89 | } 90 | } catch { 91 | // Ignore errors, extension may be disabled. 92 | } 93 | return undefined; 94 | }; 95 | -------------------------------------------------------------------------------- /src/log.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { inspect } from "util"; 3 | 4 | class Log { 5 | private readonly output = vscode.window.createOutputChannel("Buf", { 6 | log: true, 7 | }); 8 | 9 | trace(...messages: [unknown, ...unknown[]]): void { 10 | this.output.trace(this.stringify(messages)); 11 | } 12 | 13 | debug(...messages: [unknown, ...unknown[]]): void { 14 | this.output.debug(this.stringify(messages)); 15 | } 16 | 17 | info(...messages: [unknown, ...unknown[]]): void { 18 | this.output.info(this.stringify(messages)); 19 | } 20 | 21 | warn(...messages: [unknown, ...unknown[]]): void { 22 | this.output.warn(this.stringify(messages)); 23 | } 24 | 25 | error(...messages: [unknown, ...unknown[]]): void { 26 | this.output.error(this.stringify(messages)); 27 | this.output.show(true); 28 | } 29 | 30 | show(): void { 31 | this.output.show(true); 32 | } 33 | 34 | private stringify(messages: unknown[]): string { 35 | return messages 36 | .map((message) => { 37 | if (typeof message === "string") { 38 | return message; 39 | } 40 | if (message instanceof Error) { 41 | return message.stack || message.message; 42 | } 43 | return inspect(message, { depth: 6, colors: false }); 44 | }) 45 | .join(" "); 46 | } 47 | } 48 | 49 | export const log = new Log(); 50 | -------------------------------------------------------------------------------- /src/status.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import * as commands from "./commands"; 3 | 4 | import { BufContext, ServerStatus } from "./context"; 5 | 6 | export let statusBarItem: vscode.StatusBarItem | undefined; 7 | 8 | const StatusBarItemName = "Buf"; 9 | 10 | type StatusBarConfig = { 11 | icon: string; 12 | colour?: vscode.ThemeColor; 13 | command?: string; 14 | tooltip?: string; 15 | }; 16 | 17 | const icons: Record = { 18 | [ServerStatus.SERVER_DISABLED]: { 19 | icon: "$(circle-slash)", 20 | colour: new vscode.ThemeColor("statusBarItem.warningBackground"), 21 | tooltip: "$(circle-slash) Language server disabled", 22 | }, 23 | [ServerStatus.SERVER_STARTING]: { 24 | icon: "$(sync~spin)", 25 | command: commands.showOutput.command, 26 | tooltip: "$(debug-start) Starting language server", 27 | }, 28 | [ServerStatus.SERVER_RUNNING]: { 29 | icon: "$(check)", 30 | command: commands.showCommands.command, 31 | tooltip: "$(check) Language server running", 32 | }, 33 | [ServerStatus.SERVER_STOPPED]: { 34 | icon: "$(x)", 35 | command: commands.restartBuf.command, 36 | tooltip: "$(debug-restart) Restart language server", 37 | }, 38 | [ServerStatus.SERVER_ERRORED]: { 39 | icon: "$(error)", 40 | colour: new vscode.ThemeColor("statusBarItem.errorBackground"), 41 | command: commands.restartBuf.command, 42 | tooltip: "$(debug-restart) Restart language server", 43 | }, 44 | [ServerStatus.SERVER_NOT_INSTALLED]: { 45 | icon: "$(circle-slash)", 46 | colour: new vscode.ThemeColor("statusBarItem.errorBackground"), 47 | command: commands.installBuf.command, 48 | tooltip: "$(circle-slash) Buf not installed", 49 | }, 50 | }; 51 | 52 | const busyStatusBarConfig: StatusBarConfig = { 53 | icon: "$(loading~spin)", 54 | }; 55 | 56 | export function activate(ctx: vscode.ExtensionContext, bufCtx: BufContext) { 57 | updateStatusBar(bufCtx); 58 | 59 | ctx.subscriptions.push( 60 | bufCtx.onDidChangeContext(() => { 61 | updateStatusBar(bufCtx); 62 | }) 63 | ); 64 | } 65 | 66 | const updateStatusBar = (bufCtx: BufContext) => { 67 | if (!statusBarItem) { 68 | statusBarItem = vscode.window.createStatusBarItem( 69 | StatusBarItemName, 70 | vscode.StatusBarAlignment.Right, 71 | 100 72 | ); 73 | statusBarItem.name = StatusBarItemName; 74 | statusBarItem.show(); 75 | } 76 | 77 | const config = bufCtx.busy ? busyStatusBarConfig : icons[bufCtx.status]; 78 | 79 | statusBarItem.text = `${config.icon} Buf${bufCtx.buf?.version ? ` (${bufCtx.buf.version})` : ""}`; 80 | statusBarItem.color = config.colour; 81 | statusBarItem.command = config.command || commands.showOutput.command; 82 | statusBarItem.tooltip = new vscode.MarkdownString("", true); 83 | statusBarItem.tooltip.supportHtml = true; 84 | 85 | if (config.tooltip) { 86 | statusBarItem.tooltip.appendMarkdown(`${config.tooltip}\n\n`); 87 | } 88 | }; 89 | 90 | export const disposeStatusBar = () => { 91 | if (statusBarItem) { 92 | statusBarItem.dispose(); 93 | statusBarItem = undefined; 94 | } 95 | }; 96 | -------------------------------------------------------------------------------- /src/ui.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | export const showHelp = async (message: string, url: string) => { 4 | if (await vscode.window.showInformationMessage(message, "Open website")) { 5 | vscode.env.openExternal(vscode.Uri.parse(url)); 6 | } 7 | }; 8 | 9 | export const slow = (title: string, result: Promise) => { 10 | const opts = { 11 | location: vscode.ProgressLocation.Notification, 12 | title: title, 13 | cancellable: false, 14 | }; 15 | return Promise.resolve(vscode.window.withProgress(opts, () => result)); 16 | }; 17 | 18 | export const progress = ( 19 | title: string, 20 | cancel: AbortController | null, 21 | body: (progress: (fraction: number) => void) => Promise 22 | ) => { 23 | const opts = { 24 | location: vscode.ProgressLocation.Notification, 25 | title: title, 26 | cancellable: cancel !== null, 27 | }; 28 | const result = vscode.window.withProgress(opts, async (progress, canc) => { 29 | if (cancel) { 30 | canc.onCancellationRequested((_) => cancel.abort()); 31 | } 32 | let lastFraction = 0; 33 | return body((fraction) => { 34 | if (fraction > lastFraction) { 35 | progress.report({ increment: 100 * (fraction - lastFraction) }); 36 | lastFraction = fraction; 37 | } 38 | }); 39 | }); 40 | return Promise.resolve(result); // Thenable to real promise. 41 | }; 42 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import * as cp from "child_process"; 2 | import * as fs from "fs"; 3 | import * as path from "path"; 4 | 5 | import { promisify } from "util"; 6 | import { progress } from "./ui"; 7 | import { pipeline, Transform } from "stream"; 8 | 9 | export const pipelineAsync = promisify(pipeline); 10 | 11 | export const execFile = promisify(cp.execFile); 12 | 13 | export const download = async ( 14 | url: string, 15 | dest: string, 16 | abort: AbortController 17 | ): Promise => { 18 | return progress( 19 | `Downloading ${path.basename(dest)}`, 20 | abort, 21 | async (progress) => { 22 | const response = await fetch(url, { signal: abort.signal }); 23 | if (!response.ok || response.body === null) { 24 | throw new Error(`Can't fetch ${url}: ${response.statusText}`); 25 | } 26 | 27 | const size = Number(response.headers.get("content-length")) || 0; 28 | let read = 0; 29 | const out = fs.createWriteStream(dest); 30 | 31 | const progressStream = new Transform({ 32 | transform(chunk, _, callback) { 33 | read += chunk.length; 34 | if (size > 0) { 35 | progress(read / size); 36 | } 37 | callback(null, chunk); 38 | }, 39 | }); 40 | 41 | try { 42 | await pipelineAsync(response.body, progressStream, out); 43 | } catch (e) { 44 | fs.unlink(dest, (_) => null); 45 | throw e; 46 | } 47 | } 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | import * as semver from "semver"; 2 | import * as github from "./github"; 3 | 4 | import { execFile } from "./util"; 5 | 6 | const loose: semver.Options = { 7 | loose: true, 8 | }; 9 | 10 | export interface Upgrade { 11 | old: string; 12 | new: string; 13 | upgrade: boolean; 14 | } 15 | 16 | export class BufVersion { 17 | constructor( 18 | public readonly path: string, 19 | public readonly version: semver.Range 20 | ) {} 21 | 22 | static async fromPath(path: string): Promise { 23 | const version = await getBufVersion(path); 24 | return new BufVersion(path, version); 25 | } 26 | 27 | hasUpgrade(release: github.Release): Upgrade { 28 | const releasedVer = getReleaseVersion(release); 29 | return { 30 | old: this.version.raw, 31 | new: releasedVer.raw, 32 | upgrade: rangeGreater(releasedVer, this.version), 33 | }; 34 | } 35 | } 36 | 37 | export const getBufVersion = async (bufPath: string): Promise => { 38 | const { stdout, stderr } = await execFile(bufPath, ["--version"]); 39 | 40 | if (stderr) { 41 | throw new Error(`Error getting version of '${bufPath}'! ${stderr}`); 42 | } 43 | 44 | // Some vendors add trailing ~patchlevel, ignore this. 45 | const rawVersion = stdout.trim().split(/\s|~/, 1)[0]; 46 | 47 | if (!rawVersion) { 48 | throw new Error(`Unable to determine version of '${bufPath}'!`); 49 | } 50 | 51 | return new semver.Range(rawVersion, loose); 52 | }; 53 | 54 | // Get the version of a github release, by parsing the tag or name. 55 | const getReleaseVersion = (release: github.Release): semver.Range => { 56 | // Prefer the tag name, but fall back to the release name. 57 | return !semver.validRange(release.tag_name, loose) && 58 | semver.validRange(release.name, loose) 59 | ? new semver.Range(release.name, loose) 60 | : new semver.Range(release.tag_name, loose); 61 | }; 62 | 63 | const rangeGreater = (newVer: semver.Range, oldVer: semver.Range) => { 64 | const minVersion = semver.minVersion(newVer); 65 | if (minVersion === null) { 66 | throw new Error(`Couldn't parse version range: ${newVer}`); 67 | } 68 | return semver.gtr(minVersion, oldVer); 69 | }; 70 | -------------------------------------------------------------------------------- /syntaxes/proto.tmLanguage.LICENSE: -------------------------------------------------------------------------------- 1 | Original license for proto.tmLanguage.json: 2 | 3 | MIT License 4 | 5 | Copyright (c) 2022 pbkit 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /syntaxes/proto.tmLanguage.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Protocol Buffer", 3 | "scopeName": "source.proto", 4 | "fileTypes": ["proto"], 5 | 6 | "patterns": [ 7 | { "include": "#comments" }, 8 | { "include": "#syntax" }, 9 | { "include": "#package" }, 10 | { "include": "#import" }, 11 | { "include": "#optionStmt" }, 12 | { "include": "#message" }, 13 | { "include": "#enum" }, 14 | { "include": "#service" }, 15 | { "include": "#field" }, 16 | { "include": "#mapfield" }, 17 | { "include": "#enumValue" } 18 | ], 19 | 20 | "repository": { 21 | "comments": { 22 | "patterns": [ 23 | { 24 | "name": "comment.block.proto", 25 | "begin": "/\\*", 26 | "end": "\\*/" 27 | }, 28 | { 29 | "name": "comment.line.double-slash.proto", 30 | "begin": "//", 31 | "end": "$\\n?" 32 | } 33 | ] 34 | }, 35 | "syntax": { 36 | "match": "\\s*(syntax|edition)\\s*(=)?\\s*(\"[\\w.]+\")?\\s*(;)?", 37 | "captures": { 38 | "1": { "name": "keyword.other.proto" }, 39 | "2": { "name": "keyword.operator.assignment.proto" }, 40 | "3": { "name": "string.quoted.double.proto.syntax" }, 41 | "4": { "name": "punctuation.terminator.proto" } 42 | } 43 | }, 44 | "package": { 45 | "match": "\\s*(package)\\s+([\\w.]+)\\s*(;)?", 46 | "captures": { 47 | "1": { "name": "keyword.other.proto" }, 48 | "2": { "name": "string.unquoted.proto.package" }, 49 | "3": { "name": "punctuation.terminator.proto" } 50 | } 51 | }, 52 | "import": { 53 | "match": "\\s*(import)\\s+(weak|public)?\\s*(\"[^\"]+\")?\\s*(;)?", 54 | "captures": { 55 | "1": { "name": "keyword.other.proto" }, 56 | "2": { "name": "keyword.other.proto" }, 57 | "3": { "name": "string.quoted.double.proto.import" }, 58 | "4": { "name": "punctuation.terminator.proto" } 59 | } 60 | }, 61 | "optionStmt": { 62 | "begin": "(option)\\s+(\\w+|\\(\\w+(\\.\\w+)*\\))(\\.\\w+)*\\s*(=)?", 63 | "beginCaptures": { 64 | "1": { "name": "keyword.other.proto" }, 65 | "2": { "name": "variable.other.proto" }, 66 | "3": { "name": "variable.other.proto" }, 67 | "4": { "name": "variable.other.proto" }, 68 | "5": { "name": "keyword.operator.assignment.proto" } 69 | }, 70 | "end": "(;)", 71 | "endCaptures": { 72 | "1": { "name": "punctuation.terminator.proto" } 73 | }, 74 | "patterns": [ 75 | { "include": "#constants" }, 76 | { "include": "#number" }, 77 | { "include": "#string" }, 78 | { "include": "#subMsgOption" } 79 | ] 80 | }, 81 | "subMsgOption": { 82 | "begin": "\\{", 83 | "end": "\\}", 84 | "patterns": [ 85 | { "include": "#kv" }, 86 | { "include": "#comments" }, 87 | { "include": "#msgLitConstants" }, 88 | { 89 | "match": "(,)|(;)", 90 | "captures": { 91 | "1": { "name": "punctuation.separator.proto" }, 92 | "2": { "name": "punctuation.terminator.proto" } 93 | } 94 | } 95 | ] 96 | }, 97 | "kv": { 98 | "begin": "(\\w+)|(\\[\\s*(((\\w+)|(.)|(/))//s*)*\\])\\s*(:|(?={))", 99 | "beginCaptures": { 100 | "1": { "name": "variable.other.proto" }, 101 | "5": { "name": "variable.other.proto" }, 102 | "6": { "name": "punctuation.separator.proto" }, 103 | "7": { "name": "punctuation.separator.proto" }, 104 | "8": { "name": "punctuation.separator.key-value.proto" } 105 | }, 106 | "end": "(;)|(,)|(?=[}/_a-zA-Z])", 107 | "endCaptures": { 108 | "1": { "name": "punctuation.terminator.proto" }, 109 | "2": { "name": "punctuation.separator.proto" } 110 | }, 111 | "patterns": [ 112 | { "include": "#msgLitConstants" }, 113 | { "include": "#number" }, 114 | { "include": "#string" }, 115 | { "include": "#list" }, 116 | { "include": "#subMsgOption" } 117 | ] 118 | }, 119 | "list": { 120 | "begin": "\\[", 121 | "end": "\\]", 122 | "patterns": [ 123 | { "include": "#constants" }, 124 | { "include": "#number" }, 125 | { "include": "#string" }, 126 | { "include": "#subMsgOption" }, 127 | { 128 | "match": "(,)", 129 | "captures": { 130 | "1": { "name": "punctuation.separator.proto" } 131 | } 132 | } 133 | ] 134 | }, 135 | "compactOptions": { 136 | "begin": "\\[", 137 | "end": "\\]", 138 | "patterns": [ 139 | { "include": "#constants" }, 140 | { "include": "#number" }, 141 | { "include": "#string" }, 142 | { "include": "#subMsgOption" }, 143 | { "include": "#optionName" }, 144 | { 145 | "match": "(=)|(,)|(.)", 146 | "captures": { 147 | "1": { "name": "keyword.operator.assignment.proto" }, 148 | "2": { "name": "punctuation.separator.proto" }, 149 | "3": { "name": "punctuation.separator.proto" } 150 | } 151 | } 152 | ] 153 | }, 154 | "optionName": { 155 | "match": "(\\w+)", 156 | "captures": { 157 | "1": { "name": "variable.other.proto" } 158 | } 159 | }, 160 | "message": { 161 | "begin": "(message|extend)(\\s+)([A-Za-z_][A-Za-z0-9_.]*)(\\s*)(\\{)?", 162 | "beginCaptures": { 163 | "1": { "name": "keyword.other.proto" }, 164 | "3": { "name": "entity.name.class.message.proto" } 165 | }, 166 | "end": "\\}", 167 | "patterns": [ 168 | { "include": "#reserved" }, 169 | { "include": "#group" }, 170 | { "include": "$self" }, 171 | { "include": "#enum" }, 172 | { "include": "#optionStmt" }, 173 | { "include": "#comments" }, 174 | { "include": "#oneof" }, 175 | { "include": "#field" }, 176 | { "include": "#mapfield" } 177 | ] 178 | }, 179 | "reserved": { 180 | "begin": "(reserved|extensions)\\s+", 181 | "beginCaptures": { 182 | "1": { "name": "keyword.other.proto" } 183 | }, 184 | "end": "(;)", 185 | "endCaptures": { 186 | "1": { "name": "punctuation.terminator.proto" } 187 | }, 188 | "patterns": [ 189 | { 190 | "match": "(\\d+)(\\s+(to)\\s+((\\d+)|(max)))?\\s*(,)?", 191 | "captures": { 192 | "1": { "name": "constant.numeric.proto" }, 193 | "3": { "name": "keyword.other.proto" }, 194 | "5": { "name": "constant.numeric.proto" }, 195 | "6": { "name": "keyword.other.proto" }, 196 | "7": { "name": "punctuation.separator.proto" } 197 | } 198 | }, 199 | { "include": "#string" }, 200 | { "include": "#compactOptions" } 201 | ] 202 | }, 203 | "group": { 204 | "begin": "\\s*(optional|repeated|required)?\\s*\\b(group)\\s+([A-Za-z_][A-Za-z0-9_.]*)?\\s*(=)?\\s*(0[xX][0-9a-fA-F]+|[0-9]+)?", 205 | "beginCaptures": { 206 | "1": { "name": "keyword.other.proto" }, 207 | "2": { "name": "keyword.other.proto" }, 208 | "3": { "name": "entity.name.class.message.proto" }, 209 | "4": { "name": "keyword.operator.assignment.proto" }, 210 | "5": { "name": "constant.numeric.proto" } 211 | }, 212 | "end": "\\}", 213 | "patterns": [ 214 | { "include": "#reserved" }, 215 | { "include": "$self" }, 216 | { "include": "#message" }, 217 | { "include": "#enum" }, 218 | { "include": "#optionStmt" }, 219 | { "include": "#comments" }, 220 | { "include": "#oneof" }, 221 | { "include": "#field" }, 222 | { "include": "#mapfield" }, 223 | { "include": "#compactOptions" } 224 | ] 225 | }, 226 | "field": { 227 | "begin": "\\s*(optional|repeated|required)?\\s*\\b([\\w\\s*.\\s*]+)\\s+(\\w+)\\s*(=)\\s*(0[xX][0-9a-fA-F]+|[0-9]+)", 228 | "beginCaptures": { 229 | "1": { "name": "storage.modifier.proto" }, 230 | "2": { "name": "storage.type.proto" }, 231 | "3": { "name": "variable.other.proto" }, 232 | "4": { "name": "keyword.operator.assignment.proto" }, 233 | "5": { "name": "constant.numeric.proto" } 234 | }, 235 | "end": "(;)", 236 | "endCaptures": { 237 | "1": { "name": "punctuation.terminator.proto" } 238 | }, 239 | "patterns": [{ "include": "#compactOptions" }] 240 | }, 241 | "mapfield": { 242 | "begin": "\\s*(map)\\s*(<)\\s*([\\w\\s*.\\s*]+)\\s*(,)\\s*([\\w\\s*.\\s*]+)\\s*(>)\\s+(\\w+)\\s*(=)\\s*(\\d+)", 243 | "beginCaptures": { 244 | "1": { "name": "storage.type.proto" }, 245 | "2": { "name": "punctuation.definition.typeparameters.begin.proto" }, 246 | "3": { "name": "storage.type.proto" }, 247 | "4": { "name": "punctuation.separator.proto" }, 248 | "5": { "name": "storage.type.proto" }, 249 | "6": { "name": "punctuation.definition.typeparameters.end.proto" }, 250 | "7": { "name": "variable.other.proto" }, 251 | "8": { "name": "keyword.operator.assignment.proto" }, 252 | "9": { "name": "constant.numeric.proto" } 253 | }, 254 | "end": "(;)", 255 | "endCaptures": { 256 | "1": { "name": "punctuation.terminator.proto" } 257 | }, 258 | "patterns": [{ "include": "#compactOptions" }] 259 | }, 260 | "oneof": { 261 | "begin": "(oneof)\\s+([A-Za-z][A-Za-z0-9_]*)\\s*\\{?", 262 | "beginCaptures": { 263 | "1": { "name": "keyword.other.proto" }, 264 | "2": { "name": "variable.other.proto" } 265 | }, 266 | "end": "\\}", 267 | "patterns": [ 268 | { "include": "#optionStmt" }, 269 | { "include": "#comments" }, 270 | { "include": "#group" }, 271 | { "include": "#field" } 272 | ] 273 | }, 274 | "enum": { 275 | "begin": "(enum)(\\s+)([A-Za-z][A-Za-z0-9_]*)(\\s*)(\\{)?", 276 | "beginCaptures": { 277 | "1": { "name": "keyword.other.proto" }, 278 | "3": { "name": "entity.name.class.proto" } 279 | }, 280 | "end": "\\}", 281 | "patterns": [ 282 | { "include": "#reserved" }, 283 | { "include": "#optionStmt" }, 284 | { "include": "#comments" }, 285 | { "include": "#enumValue" } 286 | ] 287 | }, 288 | "enumValue": { 289 | "begin": "([A-Za-z][A-Za-z0-9_]*)\\s*(=)\\s*(0[xX][0-9a-fA-F]+|[0-9]+)", 290 | "beginCaptures": { 291 | "1": { "name": "variable.other.proto" }, 292 | "2": { "name": "keyword.operator.assignment.proto" }, 293 | "3": { "name": "constant.numeric.proto" } 294 | }, 295 | "end": "(;)", 296 | "endCaptures": { 297 | "1": { "name": "punctuation.terminator.proto" } 298 | }, 299 | "patterns": [{ "include": "#compactOptions" }] 300 | }, 301 | "service": { 302 | "begin": "(service)\\s+([A-Za-z][A-Za-z0-9_.]*)\\s*\\{?", 303 | "beginCaptures": { 304 | "1": { "name": "keyword.other.proto" }, 305 | "2": { "name": "entity.name.class.message.proto" } 306 | }, 307 | "end": "\\}", 308 | "patterns": [ 309 | { "include": "#comments" }, 310 | { "include": "#optionStmt" }, 311 | { "include": "#method" } 312 | ] 313 | }, 314 | "method": { 315 | "begin": "(rpc)\\s+([A-Za-z][A-Za-z0-9_]*)", 316 | "beginCaptures": { 317 | "1": { "name": "keyword.other.proto" }, 318 | "2": { "name": "entity.name.function" } 319 | }, 320 | "end": "\\}|(;)", 321 | "endCaptures": { 322 | "1": { "name": "punctuation.terminator.proto" } 323 | }, 324 | "patterns": [ 325 | { "include": "#comments" }, 326 | { "include": "#optionStmt" }, 327 | { "include": "#rpcKeywords" }, 328 | { "include": "#ident" } 329 | ] 330 | }, 331 | "rpcKeywords": { 332 | "match": "\\b(stream|returns)\\b", 333 | "name": "keyword.other.proto" 334 | }, 335 | "ident": { 336 | "match": "[A-Za-z][A-Za-z0-9_]*", 337 | "name": "entity.name.class.proto" 338 | }, 339 | "constants": { 340 | "match": "\\b(true|false|max|nan|inf|[A-Z_]+)\\b", 341 | "name": "constant.language.proto" 342 | }, 343 | "msgLitConstants": { 344 | "match": "\\b(True|true|t|False|false|f|max|nan|inf|[A-Z_]+)\\b", 345 | "name": "constant.language.proto" 346 | }, 347 | "storagetypes": { 348 | "match": "\\b(double|float|int32|int64|uint32|uint64|sint32|sint64|fixed32|fixed64|sfixed32|sfixed64|bool|string|bytes)\\b", 349 | "name": "storage.type.proto" 350 | }, 351 | "string": { 352 | "match": "('([^']|\\')*')|(\"([^\"]|\\\")*\")", 353 | "name": "string.quoted.double.proto" 354 | }, 355 | "number": { 356 | "name": "constant.numeric.proto", 357 | "match": "\\b((0(x|X)[0-9a-fA-F]*)|(([0-9]+\\.?[0-9]*)|(\\.[0-9]+))((e|E)(\\+|-)?[0-9]+)?)\\b" 358 | } 359 | } 360 | } 361 | -------------------------------------------------------------------------------- /test-workspaces/npm-buf-workspace/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "buf.binaryPath": "./node_modules/.bin/buf" 3 | } 4 | -------------------------------------------------------------------------------- /test-workspaces/npm-buf-workspace/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "workspace", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "workspace", 9 | "version": "1.0.0", 10 | "license": "ISC", 11 | "dependencies": { 12 | "@bufbuild/buf": "^1.29.0" 13 | } 14 | }, 15 | "node_modules/@bufbuild/buf": { 16 | "version": "1.29.0", 17 | "resolved": "https://registry.npmjs.org/@bufbuild/buf/-/buf-1.29.0.tgz", 18 | "integrity": "sha512-euksXeFtvlvAV5j94LqXb69qQcJvFfo8vN1d3cx+IzhOKoipykuQQTq7mOWVo2R0kdk6yIMBLBofOYOsh0Df8g==", 19 | "hasInstallScript": true, 20 | "bin": { 21 | "buf": "bin/buf", 22 | "protoc-gen-buf-breaking": "bin/protoc-gen-buf-breaking", 23 | "protoc-gen-buf-lint": "bin/protoc-gen-buf-lint" 24 | }, 25 | "engines": { 26 | "node": ">=12" 27 | }, 28 | "optionalDependencies": { 29 | "@bufbuild/buf-darwin-arm64": "1.29.0", 30 | "@bufbuild/buf-darwin-x64": "1.29.0", 31 | "@bufbuild/buf-linux-aarch64": "1.29.0", 32 | "@bufbuild/buf-linux-x64": "1.29.0", 33 | "@bufbuild/buf-win32-arm64": "1.29.0", 34 | "@bufbuild/buf-win32-x64": "1.29.0" 35 | } 36 | }, 37 | "node_modules/@bufbuild/buf-darwin-arm64": { 38 | "version": "1.29.0", 39 | "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.29.0.tgz", 40 | "integrity": "sha512-5hKxsARoY2WpWq1n5ONFqqGuauHb4yILKXCy37KRYCKiRLWmIP5yI3gWvWHKoH7sUJWTQmBqdJoCvYQr6ahQnw==", 41 | "cpu": [ 42 | "arm64" 43 | ], 44 | "optional": true, 45 | "os": [ 46 | "darwin" 47 | ], 48 | "engines": { 49 | "node": ">=12" 50 | } 51 | }, 52 | "node_modules/@bufbuild/buf-darwin-x64": { 53 | "version": "1.29.0", 54 | "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.29.0.tgz", 55 | "integrity": "sha512-wOAPxbPLBns4AHiComWtdO1sx1J1p6mDYTbqmloHuI+B5U2rDbMsoHoe4nBcoMF8+RHxoqjypha29wVo6yzbZg==", 56 | "cpu": [ 57 | "x64" 58 | ], 59 | "optional": true, 60 | "os": [ 61 | "darwin" 62 | ], 63 | "engines": { 64 | "node": ">=12" 65 | } 66 | }, 67 | "node_modules/@bufbuild/buf-linux-aarch64": { 68 | "version": "1.29.0", 69 | "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.29.0.tgz", 70 | "integrity": "sha512-jLk2J/wyyM7KNJ/DkLfhy3eS2/Bdb70e/56adMkapSoLJmghnpgxW+oFznMxxQUX5I9BU5hTn1UhDFxgLwhP7g==", 71 | "cpu": [ 72 | "arm64" 73 | ], 74 | "optional": true, 75 | "os": [ 76 | "linux" 77 | ], 78 | "engines": { 79 | "node": ">=12" 80 | } 81 | }, 82 | "node_modules/@bufbuild/buf-linux-x64": { 83 | "version": "1.29.0", 84 | "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.29.0.tgz", 85 | "integrity": "sha512-heLOywj3Oaoh69RnTx7tHsuz6rEnvz77bghLEOghsrjBR6Jcpcwc137EZR4kRTIWJNrE8Kmo3RVeXlv144qQIQ==", 86 | "cpu": [ 87 | "x64" 88 | ], 89 | "optional": true, 90 | "os": [ 91 | "linux" 92 | ], 93 | "engines": { 94 | "node": ">=12" 95 | } 96 | }, 97 | "node_modules/@bufbuild/buf-win32-arm64": { 98 | "version": "1.29.0", 99 | "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.29.0.tgz", 100 | "integrity": "sha512-Eglyvr3PLqVucuHBcQ61conyBgH9BRaoLpKWcce1gYBVlxMQM1NxjVjGOWihxQ1dXXw5qZXmYfVODf3gSwPMuQ==", 101 | "cpu": [ 102 | "arm64" 103 | ], 104 | "optional": true, 105 | "os": [ 106 | "win32" 107 | ], 108 | "engines": { 109 | "node": ">=12" 110 | } 111 | }, 112 | "node_modules/@bufbuild/buf-win32-x64": { 113 | "version": "1.29.0", 114 | "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.29.0.tgz", 115 | "integrity": "sha512-wRk6co+nqHqEq4iLolXgej0jUVlWlTtGHjKaq54lTbKZrwxrBgql6qS06abgNPRASX0++XT9m3QRZ97qEIC/HQ==", 116 | "cpu": [ 117 | "x64" 118 | ], 119 | "optional": true, 120 | "os": [ 121 | "win32" 122 | ], 123 | "engines": { 124 | "node": ">=12" 125 | } 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /test-workspaces/npm-buf-workspace/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "workspace", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "@bufbuild/buf": "^1.29.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/e2e/base-test.ts: -------------------------------------------------------------------------------- 1 | import { test as baseTest, type Page, _electron } from "@playwright/test"; 2 | import { 3 | downloadAndUnzipVSCode, 4 | resolveCliArgsFromVSCodeExecutablePath, 5 | } from "@vscode/test-electron"; 6 | export { expect } from "@playwright/test"; 7 | import path from "node:path"; 8 | import fs from "node:fs"; 9 | import os from "node:os"; 10 | 11 | export type TestOptions = { 12 | vsCodeVersion: string; 13 | }; 14 | 15 | async function createFile(filePath: string, content: string): Promise { 16 | // ensure the full path up to the file exists 17 | const dir = path.dirname(filePath); 18 | await fs.promises.mkdir(dir, { recursive: true }); 19 | return fs.promises.writeFile(filePath, content); 20 | } 21 | 22 | type TestFixtures = TestOptions & { 23 | workbox: { 24 | projectPath: string; 25 | createFile: typeof createFile; 26 | page: Page; 27 | }; 28 | createProject: () => Promise; 29 | createTempDir: () => Promise; 30 | }; 31 | 32 | export const test = baseTest.extend({ 33 | vsCodeVersion: [process.env.VSCODE_VERSION ?? "insiders", { option: true }], 34 | workbox: async ({ vsCodeVersion, createProject, createTempDir }, use) => { 35 | const defaultCachePath = await createTempDir(); 36 | const vscodeExecutablePath = await downloadAndUnzipVSCode(vsCodeVersion); 37 | const [cliPath] = 38 | resolveCliArgsFromVSCodeExecutablePath(vscodeExecutablePath); 39 | const projectPath = await createProject(); 40 | 41 | const electronApp = await _electron.launch({ 42 | executablePath: cliPath, 43 | args: [ 44 | "--verbose", 45 | // Stolen from https://github.com/microsoft/vscode-test/blob/0ec222ef170e102244569064a12898fb203e5bb7/lib/runTest.ts#L126-L160 46 | // https://github.com/microsoft/vscode/issues/84238 47 | "--no-sandbox", 48 | // https://github.com/microsoft/vscode-test/issues/221 49 | "--disable-gpu-sandbox", 50 | // https://github.com/microsoft/vscode-test/issues/120 51 | "--disable-updates", 52 | "--skip-welcome", 53 | "--skip-release-notes", 54 | "--disable-workspace-trust", 55 | `--extensionDevelopmentPath=${path.join(__dirname, "..", "..")}`, 56 | `--extensions-dir=${path.join(defaultCachePath, "extensions")}`, 57 | `--user-data-dir=${path.join(defaultCachePath, "user-data")}`, 58 | projectPath, 59 | ], 60 | }); 61 | 62 | const page = await electronApp.firstWindow(); 63 | await page.context().tracing.start({ 64 | screenshots: true, 65 | snapshots: true, 66 | title: test.info().title, 67 | }); 68 | await use({ 69 | page, 70 | projectPath, 71 | createFile: (filePath, content) => { 72 | return createFile(path.join(projectPath, filePath), content); 73 | }, 74 | }); 75 | const tracePath = test.info().outputPath("trace.zip"); 76 | await page.context().tracing.stop({ path: tracePath }); 77 | test.info().attachments.push({ 78 | name: "trace", 79 | path: tracePath, 80 | contentType: "application/zip", 81 | }); 82 | await electronApp.close(); 83 | // Add a small delay on Windows to ensure processes are fully released 84 | if (process.platform === "win32") { 85 | await new Promise((resolve) => setTimeout(resolve, 2000)); 86 | } 87 | const logPath = path.join(defaultCachePath, "user-data"); 88 | if (fs.existsSync(logPath)) { 89 | const logOutputPath = test.info().outputPath("vscode-logs"); 90 | await fs.promises.cp(logPath, logOutputPath, { recursive: true }); 91 | } 92 | }, 93 | createProject: async ({ createTempDir }, use) => { 94 | await use(async () => { 95 | // We want to be outside of the project directory to avoid already installed dependencies. 96 | const projectPath = await createTempDir(); 97 | if (fs.existsSync(projectPath)) { 98 | await fs.promises.rm(projectPath, { recursive: true }); 99 | } 100 | await fs.promises.mkdir(path.join(projectPath, ".vscode"), { 101 | recursive: true, 102 | }); 103 | // Setup some default settings for the project. 104 | await fs.promises.appendFile( 105 | path.join(projectPath, ".vscode", "settings.json"), 106 | JSON.stringify({ 107 | git: { 108 | openRepositoryInParentFolders: true, 109 | }, 110 | }) 111 | ); 112 | return projectPath; 113 | }); 114 | }, 115 | // eslint-disable-next-line no-empty-pattern 116 | createTempDir: async ({}, use) => { 117 | const tempDirs: string[] = []; 118 | await use(async () => { 119 | const tempDir = await fs.promises.realpath( 120 | await fs.promises.mkdtemp(path.join(os.tmpdir(), "pwtest-")) 121 | ); 122 | tempDirs.push(tempDir); 123 | return tempDir; 124 | }); 125 | 126 | // Process each temp directory 127 | for (const tempDir of tempDirs) { 128 | if (process.platform === "win32") { 129 | // Windows-specific cleanup with retry logic 130 | let attempts = 0; 131 | const maxAttempts = 3; 132 | 133 | while (attempts < maxAttempts) { 134 | try { 135 | await fs.promises.rm(tempDir, { recursive: true }); 136 | break; // Success, exit the retry loop 137 | } catch (_error) { 138 | attempts++; 139 | if (attempts >= maxAttempts) { 140 | console.warn( 141 | `Failed to remove directory after ${maxAttempts} attempts: ${tempDir}` 142 | ); 143 | break; 144 | } 145 | // Wait before retrying 146 | await new Promise((resolve) => setTimeout(resolve, 1000)); 147 | } 148 | } 149 | } else { 150 | // Standard cleanup for non-Windows platforms 151 | await fs.promises.rm(tempDir, { recursive: true }); 152 | } 153 | } 154 | }, 155 | }); 156 | -------------------------------------------------------------------------------- /test/e2e/extension.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "./base-test"; 2 | 3 | const baseBufYaml = `version: v2 4 | 5 | modules: 6 | - path: example 7 | name: example`; 8 | 9 | const baseBufGenYaml = `version: v2 10 | managed: 11 | enabled: true 12 | plugins: 13 | - remote: buf.build/bufbuild/es:v2.2.2 14 | out: gen-es 15 | opt: target=ts 16 | 17 | inputs: 18 | - module: buf.build/bufbuild/registry`; 19 | 20 | const exampleUserProto = `syntax = "proto3"; 21 | 22 | package example.v1; 23 | 24 | import "google/protobuf/timestamp.proto"; 25 | import "google/protobuf/empty.proto"; 26 | 27 | // UserService is a simple example service that demonstrates 28 | // basic CRUD operations for user management. 29 | service UserService { 30 | // GetUser retrieves a user by ID 31 | rpc GetUser(GetUserRequest) returns (GetUserResponse) {} 32 | 33 | // ListUsers retrieves all users with optional filtering 34 | rpc ListUsers(ListUsersRequest) returns (ListUsersResponse) {} 35 | 36 | // CreateUser creates a new user 37 | rpc CreateUser(CreateUserRequest) returns (CreateUserResponse) {} 38 | 39 | // UpdateUser updates an existing user 40 | rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse) {} 41 | 42 | // DeleteUser removes a user by ID 43 | rpc DeleteUser(DeleteUserRequest) returns (google.protobuf.Empty) {} 44 | 45 | // StreamUserUpdates provides a stream of user updates 46 | rpc StreamUserUpdates(google.protobuf.Empty) returns (stream UserEvent) {} 47 | } 48 | 49 | // User represents a user in the system 50 | message User { 51 | string id = 1; 52 | string email = 2; 53 | string display_name = 3; 54 | google.protobuf.Timestamp created_at = 4; 55 | google.protobuf.Timestamp updated_at = 5; 56 | UserStatus status = 6; 57 | UserRole role = 7; 58 | 59 | // Address is a nested message type 60 | message Address { 61 | string street = 1; 62 | string city = 2; 63 | string state = 3; 64 | string country = 4; 65 | string postal_code = 5; 66 | 67 | // AddressType defines the type of address 68 | enum AddressType { 69 | ADDRESS_TYPE_UNSPECIFIED = 0; 70 | ADDRESS_TYPE_HOME = 1; 71 | ADDRESS_TYPE_WORK = 2; 72 | ADDRESS_TYPE_BILLING = 3; 73 | ADDRESS_TYPE_SHIPPING = 4; 74 | } 75 | 76 | AddressType type = 6; 77 | } 78 | 79 | repeated Address addresses = 8; 80 | map metadata = 9; 81 | 82 | reserved 10, 11, 12; 83 | reserved "password", "secret"; 84 | } 85 | 86 | // UserStatus represents the status of a user account 87 | enum UserStatus { 88 | USER_STATUS_UNSPECIFIED = 0; 89 | USER_STATUS_ACTIVE = 1; 90 | USER_STATUS_INACTIVE = 2; 91 | USER_STATUS_SUSPENDED = 3; 92 | USER_STATUS_PENDING = 4; 93 | } 94 | 95 | // UserRole defines the role of a user 96 | enum UserRole { 97 | USER_ROLE_UNSPECIFIED = 0; 98 | USER_ROLE_ADMIN = 1; 99 | USER_ROLE_USER = 2; 100 | USER_ROLE_MODERATOR = 3; 101 | USER_ROLE_GUEST = 4; 102 | } 103 | 104 | // GetUserRequest is used to retrieve a user by ID 105 | message GetUserRequest { 106 | string user_id = 1; 107 | } 108 | 109 | // GetUserResponse contains the user data 110 | message GetUserResponse { 111 | User user = 1; 112 | } 113 | 114 | // ListUsersRequest is used to retrieve multiple users 115 | message ListUsersRequest { 116 | // Pagination parameters 117 | int32 page_size = 1; 118 | string page_token = 2; 119 | 120 | // Optional filters 121 | optional UserStatus status = 3; 122 | optional UserRole role = 4; 123 | repeated string user_ids = 5; 124 | 125 | oneof time_filter { 126 | google.protobuf.Timestamp created_after = 6; 127 | google.protobuf.Timestamp created_before = 7; 128 | } 129 | } 130 | 131 | // ListUsersResponse contains a paginated list of users 132 | message ListUsersResponse { 133 | repeated User users = 1; 134 | string next_page_token = 2; 135 | int32 total_count = 3; 136 | } 137 | 138 | // CreateUserRequest is used to create a new user 139 | message CreateUserRequest { 140 | string email = 1; 141 | string display_name = 2; 142 | optional UserRole role = 3; 143 | repeated User.Address addresses = 4; 144 | map metadata = 5; 145 | } 146 | 147 | // CreateUserResponse contains the created user data 148 | message CreateUserResponse { 149 | User user = 1; 150 | } 151 | 152 | // UpdateUserRequest is used to update an existing user 153 | message UpdateUserRequest { 154 | string user_id = 1; 155 | 156 | // Fields to update 157 | optional string email = 2; 158 | optional string display_name = 3; 159 | optional UserStatus status = 4; 160 | optional UserRole role = 5; 161 | repeated User.Address addresses = 6; 162 | map metadata = 7; 163 | } 164 | 165 | // UpdateUserResponse contains the updated user data 166 | message UpdateUserResponse { 167 | User user = 1; 168 | } 169 | 170 | // DeleteUserRequest is used to delete a user 171 | message DeleteUserRequest { 172 | string user_id = 1; 173 | bool hard_delete = 2; // If true, permanently deletes the user 174 | } 175 | 176 | // UserEvent represents an event related to a user 177 | message UserEvent { 178 | enum EventType { 179 | EVENT_TYPE_UNSPECIFIED = 0; 180 | EVENT_TYPE_CREATED = 1; 181 | EVENT_TYPE_UPDATED = 2; 182 | EVENT_TYPE_DELETED = 3; 183 | EVENT_TYPE_STATUS_CHANGED = 4; 184 | } 185 | 186 | EventType event_type = 1; 187 | string user_id = 2; 188 | google.protobuf.Timestamp timestamp = 3; 189 | User user = 4; 190 | } 191 | `; 192 | 193 | test.beforeEach(async ({ workbox: { page, createFile } }) => { 194 | await createFile("buf.gen.yaml", baseBufGenYaml); 195 | await createFile("buf.yaml", baseBufYaml); 196 | await createFile("example.proto", exampleUserProto); 197 | // Highlight the proto file in explorer 198 | await page 199 | .getByRole("treeitem", { name: "example.proto" }) 200 | .locator("a") 201 | .click(); 202 | }); 203 | 204 | test("toolbar displays successful running lsp", async ({ 205 | workbox: { page }, 206 | }) => { 207 | // Validate that buf lsp is displaying success in the 208 | await expect(page.getByRole("button", { name: "check Buf" })).toBeVisible(); 209 | }); 210 | 211 | test("open command palette and run generate", async ({ workbox: { page } }) => { 212 | await page.getByRole("button", { name: "check Buf" }).click(); 213 | // Note the double space is necessayr to match 214 | await page.getByRole("option", { name: "run Generate" }).click(); 215 | // Generate should be successful and a new directory should be created 216 | await expect(page.getByRole("treeitem", { name: "gen-es" })).toBeVisible(); 217 | }); 218 | -------------------------------------------------------------------------------- /test/e2e/global-setup.ts: -------------------------------------------------------------------------------- 1 | import { downloadAndUnzipVSCode } from "@vscode/test-electron"; 2 | 3 | export default async () => { 4 | await downloadAndUnzipVSCode("insiders"); 5 | }; 6 | -------------------------------------------------------------------------------- /test/mocks/mock-context.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | import { Disposable, ExtensionContext } from "vscode"; 4 | 5 | export type ExtensionContextPlus = ExtensionContext & 6 | Pick; 7 | 8 | export class MockExtensionContext implements Partial { 9 | subscriptions: Disposable[] = []; 10 | 11 | globalStorageUri?: vscode.Uri | undefined = vscode.Uri.file("/path/to/buf"); 12 | 13 | static new(): ExtensionContextPlus { 14 | return new this() as unknown as ExtensionContextPlus; 15 | } 16 | 17 | teardown() { 18 | this.subscriptions.forEach((x) => x.dispose()); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/stubs/stub-github.ts: -------------------------------------------------------------------------------- 1 | import * as github from "../../src/github"; 2 | import * as sinon from "sinon"; 3 | 4 | export type StubGithub = sinon.SinonStubStatic; 5 | 6 | export const createStubGithub = (sandbox: sinon.SinonSandbox) => { 7 | return { 8 | getLatestRelease: sandbox.stub(github, "getRelease"), 9 | findAsset: sandbox.stub(github, "findAsset"), 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /test/stubs/stub-log.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from "sinon"; 2 | import { log } from "../../src/log"; 3 | 4 | export type StubLog = sinon.SinonStubbedInstance; 5 | 6 | export const createStubLog = (sandbox: sinon.SinonSandbox): StubLog => { 7 | return sandbox.stub(log); 8 | }; 9 | -------------------------------------------------------------------------------- /test/stubs/stub-vscode.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from "sinon"; 2 | import * as vscode from "vscode"; 3 | 4 | export type StubVscode = { 5 | window: { 6 | showInformationMessage: sinon.SinonStub; 7 | showErrorMessage: sinon.SinonStub; 8 | }; 9 | }; 10 | 11 | export const createStubVscode = (sandbox: sinon.SinonSandbox): StubVscode => { 12 | return { 13 | window: { 14 | showInformationMessage: sandbox.stub( 15 | vscode.window, 16 | "showInformationMessage" 17 | ), 18 | showErrorMessage: sandbox.stub(vscode.window, "showErrorMessage"), 19 | }, 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /test/unit/commands/buf-generate.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | 3 | import * as assert from "assert"; 4 | import * as semver from "semver"; 5 | import * as sinon from "sinon"; 6 | import * as vscode from "vscode"; 7 | import * as util from "../../../src/util"; 8 | 9 | import { bufGenerate } from "../../../src/commands/index"; 10 | import { BufContext } from "../../../src/context"; 11 | import { BufVersion } from "../../../src/version"; 12 | import { MockExtensionContext } from "../../mocks/mock-context"; 13 | import { createStubLog, StubLog } from "../../stubs/stub-log"; 14 | import { createStubVscode, StubVscode } from "../../stubs/stub-vscode"; 15 | import { CommandCallback } from "../../../src/commands/command"; 16 | 17 | suite("commands.bufGenerate", () => { 18 | vscode.window.showInformationMessage("Start all bufGenerate tests."); 19 | 20 | let sandbox: sinon.SinonSandbox; 21 | 22 | let execFileStub: sinon.SinonStub; 23 | 24 | let stubVscode: StubVscode; 25 | let logStub: StubLog; 26 | let ctx: vscode.ExtensionContext; 27 | let bufCtx: BufContext; 28 | let callback: CommandCallback; 29 | 30 | setup(() => { 31 | sandbox = sinon.createSandbox(); 32 | 33 | execFileStub = sandbox.stub(util, "execFile"); 34 | 35 | stubVscode = createStubVscode(sandbox); 36 | logStub = createStubLog(sandbox); 37 | ctx = MockExtensionContext.new(); 38 | bufCtx = new BufContext(); 39 | 40 | callback = bufGenerate.factory(ctx, bufCtx); 41 | }); 42 | 43 | teardown(() => { 44 | sandbox.restore(); 45 | }); 46 | 47 | test("should log an error if buf is not installed", async () => { 48 | bufCtx.buf = undefined; 49 | 50 | await callback(); 51 | 52 | assert.strictEqual(logStub.error.calledOnce, true); 53 | }); 54 | 55 | test("should call 'buf generate'", async () => { 56 | bufCtx.buf = new BufVersion("/path/to/buf", new semver.Range("1.0.0")); 57 | execFileStub.resolves({ stdout: "Generated successfully", stderr: "" }); 58 | 59 | await callback(); 60 | 61 | assert.strictEqual(execFileStub.calledOnce, true); 62 | assert.deepStrictEqual(execFileStub.args[0], [ 63 | "/path/to/buf", 64 | ["generate"], 65 | { cwd: vscode.workspace.rootPath }, 66 | ]); 67 | assert.strictEqual(logStub.info.calledWith("Generated successfully"), true); 68 | }); 69 | 70 | test("should throw an error if anything is written to stderr", async () => { 71 | bufCtx.buf = new BufVersion("/path/to/buf", new semver.Range("1.0.0")); 72 | execFileStub.resolves({ stdout: "", stderr: "Error occurred" }); 73 | 74 | await callback(); 75 | 76 | assert.strictEqual(logStub.error.calledOnce, true); 77 | assert.strictEqual( 78 | logStub.error.calledWith("Error generating buf: Error occurred"), 79 | true 80 | ); 81 | }); 82 | 83 | test("should throw an error if executing buf throws an error", async () => { 84 | bufCtx.buf = new BufVersion("/path/to/buf", new semver.Range("1.0.0")); 85 | execFileStub.rejects(new Error("Execution failed")); 86 | 87 | await callback(); 88 | 89 | assert.strictEqual(logStub.error.calledOnce, true); 90 | assert.strictEqual( 91 | logStub.error.calledWith("Error generating buf: Execution failed"), 92 | true 93 | ); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /test/unit/commands/find-buf.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import * as assert from "assert"; 3 | import * as fs from "fs"; 4 | import path from "path"; 5 | import proxyquire from "proxyquire"; 6 | import * as semver from "semver"; 7 | import * as sinon from "sinon"; 8 | import * as vscode from "vscode"; 9 | import type * as findBufType from "../../../src/commands/find-buf"; 10 | import * as config from "../../../src/config"; 11 | import * as version from "../../../src/version"; 12 | 13 | import { CommandCallback } from "../../../src/commands/command"; 14 | import { bufFilename } from "../../../src/const"; 15 | import { BufContext } from "../../../src/context"; 16 | import { BufVersion } from "../../../src/version"; 17 | import { MockExtensionContext } from "../../mocks/mock-context"; 18 | import { createStubVscode, StubVscode } from "../../stubs/stub-vscode"; 19 | 20 | suite("commands.findBuf", () => { 21 | vscode.window.showInformationMessage("Start all findBuf tests."); 22 | 23 | let sandbox: sinon.SinonSandbox; 24 | 25 | let ctx: vscode.ExtensionContext; 26 | let bufCtx: BufContext; 27 | 28 | let stubVscode: StubVscode; 29 | 30 | let findBufMod: typeof findBufType; 31 | let whichStub: sinon.SinonStub; 32 | 33 | let callback: CommandCallback; 34 | 35 | setup(() => { 36 | sandbox = sinon.createSandbox(); 37 | 38 | stubVscode = createStubVscode(sandbox); 39 | 40 | ctx = MockExtensionContext.new(); 41 | bufCtx = new BufContext(); 42 | 43 | whichStub = sandbox.stub(); 44 | 45 | findBufMod = proxyquire("../../../src/commands/find-buf", { 46 | which: whichStub, 47 | }); 48 | 49 | callback = findBufMod.findBuf.factory(ctx, bufCtx); 50 | }); 51 | 52 | teardown(() => { 53 | sandbox.restore(); 54 | }); 55 | 56 | test("when buf.commandLine.path set in config, uses buf from config", async () => { 57 | const bufPath = "/usr/local/bin/buf"; 58 | 59 | const configStub = sandbox.stub(config, "get").returns(bufPath); 60 | 61 | const versionFromPathStub = sandbox 62 | .stub(version.BufVersion, "fromPath") 63 | .resolves(new BufVersion(bufPath, new semver.Range("1.44.15"))); 64 | 65 | await callback(); 66 | 67 | assert.strictEqual(bufCtx.buf?.path, bufPath, "Paths should match"); 68 | assert.strictEqual( 69 | versionFromPathStub.calledOnce, 70 | true, 71 | "fromPath should be called once" 72 | ); 73 | }); 74 | 75 | test("when buf.commandLine.version set, finds specific buf version in the extension storage", async () => { 76 | const storagePath = "/path/to/storage"; 77 | const bufPath = path.join(storagePath, "v1", bufFilename); 78 | 79 | sandbox.stub(ctx, "globalStorageUri").value({ 80 | fsPath: storagePath, 81 | }); 82 | 83 | sandbox 84 | .stub(fs.promises, "readdir") 85 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 86 | .resolves(["v2" as any, "v1" as any, "v3" as any]); 87 | 88 | sandbox.stub(config, "get").returns("v1"); 89 | 90 | sandbox 91 | .stub(version.BufVersion, "fromPath") 92 | .resolves(new BufVersion(bufPath, new semver.Range("1.44.15"))); 93 | 94 | await callback(); 95 | 96 | assert.strictEqual(bufCtx.buf?.path, bufPath, "buf path should match"); 97 | }); 98 | 99 | test("when buf.commandLine.version set to 'latest', finds latest buf version in the extension storage", async () => { 100 | const storagePath = "/path/to/storage"; 101 | const bufPath = path.join(storagePath, "v3", bufFilename); 102 | 103 | sandbox.stub(ctx, "globalStorageUri").value({ 104 | fsPath: storagePath, 105 | }); 106 | 107 | sandbox 108 | .stub(fs.promises, "readdir") 109 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 110 | .resolves(["v2" as any, "v1" as any, "v3" as any]); 111 | 112 | sandbox.stub(config, "get").returns("latest"); 113 | 114 | sandbox 115 | .stub(version.BufVersion, "fromPath") 116 | .resolves(new BufVersion(bufPath, new semver.Range("1.44.15"))); 117 | 118 | await callback(); 119 | 120 | assert.strictEqual(bufCtx.buf?.path, bufPath, "buf path should match"); 121 | }); 122 | 123 | test("when no path or version set in config, finds buf in the os path", async () => { 124 | const bufPath = "/usr/local/bin/buf"; 125 | whichStub.returns(bufPath); 126 | 127 | const storagePath = "/path/to/storage"; 128 | sandbox.stub(ctx, "globalStorageUri").value({ 129 | fsPath: storagePath, 130 | }); 131 | 132 | sandbox 133 | .stub(version.BufVersion, "fromPath") 134 | .resolves(new BufVersion(bufPath, new semver.Range("1.44.15"))); 135 | 136 | await callback(); 137 | 138 | assert.strictEqual(bufCtx.buf !== null, true, "bufCtx.buf should be set"); 139 | assert.strictEqual(bufCtx.buf?.path, bufPath, "buf path should match"); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /test/unit/commands/install-buf.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import * as assert from "assert"; 3 | import * as fs from "fs"; 4 | import * as semver from "semver"; 5 | import * as sinon from "sinon"; 6 | import * as vscode from "vscode"; 7 | import * as cmds from "../../../src/commands"; 8 | import * as config from "../../../src/config"; 9 | import * as github from "../../../src/github"; 10 | import * as util from "../../../src/util"; 11 | import * as version from "../../../src/version"; 12 | 13 | import { CommandCallback } from "../../../src/commands/command"; 14 | import { BufContext } from "../../../src/context"; 15 | import { BufVersion } from "../../../src/version"; 16 | import { MockExtensionContext } from "../../mocks/mock-context"; 17 | import { createStubLog, StubLog } from "../../stubs/stub-log"; 18 | import { createStubVscode, StubVscode } from "../../stubs/stub-vscode"; 19 | 20 | suite("commands.installBuf", () => { 21 | vscode.window.showInformationMessage("Start all installBuf tests."); 22 | 23 | let sandbox: sinon.SinonSandbox; 24 | 25 | let stubVscode: StubVscode; 26 | let logStub: StubLog; 27 | let ctx: vscode.ExtensionContext; 28 | let bufCtx: BufContext; 29 | let callback: CommandCallback; 30 | 31 | setup(() => { 32 | sandbox = sinon.createSandbox(); 33 | 34 | stubVscode = createStubVscode(sandbox); 35 | logStub = createStubLog(sandbox); 36 | ctx = MockExtensionContext.new(); 37 | bufCtx = new BufContext(); 38 | 39 | callback = cmds.installBuf.factory(ctx, bufCtx); 40 | }); 41 | 42 | teardown(() => { 43 | sandbox.restore(); 44 | }); 45 | 46 | test("shows error message and does nothing if 'buf.commandLine.path' is set in config", async () => { 47 | const bufPath = "/usr/local/bin/buf"; 48 | 49 | const configStub = sandbox.stub(config, "get").returns(bufPath); 50 | 51 | await callback(); 52 | 53 | assert.strictEqual( 54 | stubVscode.window.showErrorMessage.calledOnce, 55 | true, 56 | "Error message shown once" 57 | ); 58 | assert.strictEqual( 59 | configStub.calledOnce, 60 | true, 61 | "Configuration accessed once" 62 | ); 63 | }); 64 | 65 | test("updates buf if its already installed", async () => { 66 | const bufPath = "/usr/local/bin/buf"; 67 | bufCtx.buf = new BufVersion(bufPath, new semver.Range("1.34.15")); 68 | 69 | const execCmdStub = sandbox 70 | .stub(vscode.commands, "executeCommand") 71 | .resolves(); 72 | 73 | await callback(); 74 | assert.strictEqual( 75 | execCmdStub.calledOnceWith(cmds.updateBuf.command), 76 | true 77 | ); 78 | }); 79 | 80 | test("installs the latest release from github", async () => { 81 | const bufPath = "/usr/local/bin/buf"; 82 | sandbox 83 | .stub(version.BufVersion, "fromPath") 84 | .resolves(new BufVersion(bufPath, new semver.Range("1.34.15"))); 85 | 86 | const dummyRelease = { 87 | name: "v1.0.0", 88 | tag_name: "v1.0.0", 89 | assets: [], 90 | }; 91 | 92 | const dummyAsset = { 93 | name: "buf-Darwin-arm64", 94 | browser_download_url: "http://dummy", 95 | }; 96 | 97 | sandbox.stub(github, "getRelease").resolves(dummyRelease); 98 | sandbox.stub(github, "findAsset").resolves(dummyAsset); 99 | 100 | const storagePath = "/path/to/storage"; 101 | sandbox.stub(ctx, "globalStorageUri").value({ 102 | fsPath: storagePath, 103 | }); 104 | 105 | sandbox.stub(fs.promises, "mkdir").resolves(); 106 | sandbox.stub(fs.promises, "access").throws("File not found"); 107 | const downloadStub = sandbox.stub(util, "download").resolves(); 108 | sandbox.stub(fs.promises, "chmod").resolves(); 109 | 110 | const restartBufStub = sandbox.stub(cmds.restartBuf, "execute").resolves(); 111 | 112 | await callback(); 113 | 114 | assert.strictEqual(downloadStub.calledOnce, true); 115 | assert.strictEqual(bufCtx.buf?.path, bufPath); 116 | assert.strictEqual(restartBufStub.calledOnce, true); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /test/unit/commands/restart-buf.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import * as assert from "assert"; 3 | import * as semver from "semver"; 4 | import * as sinon from "sinon"; 5 | import * as vscode from "vscode"; 6 | import * as lsp from "vscode-languageclient/node"; 7 | import * as cmds from "../../../src/commands"; 8 | import * as config from "../../../src/config"; 9 | import * as util from "../../../src/util"; 10 | 11 | import { CommandCallback } from "../../../src/commands/command"; 12 | import { BufContext, ServerStatus } from "../../../src/context"; 13 | import { BufVersion } from "../../../src/version"; 14 | import { MockExtensionContext } from "../../mocks/mock-context"; 15 | import { createStubLog, StubLog } from "../../stubs/stub-log"; 16 | import { createStubVscode, StubVscode } from "../../stubs/stub-vscode"; 17 | 18 | suite("commands.restartBuf", () => { 19 | vscode.window.showInformationMessage("Start all restartBuf tests."); 20 | 21 | let sandbox: sinon.SinonSandbox; 22 | let execFileStub: sinon.SinonStub; 23 | 24 | let stubVscode: StubVscode; 25 | let logStub: StubLog; 26 | let ctx: vscode.ExtensionContext; 27 | let bufCtx: BufContext; 28 | let callback: CommandCallback; 29 | 30 | setup(() => { 31 | sandbox = sinon.createSandbox(); 32 | 33 | execFileStub = sandbox.stub(util, "execFile"); 34 | 35 | stubVscode = createStubVscode(sandbox); 36 | logStub = createStubLog(sandbox); 37 | ctx = MockExtensionContext.new(); 38 | bufCtx = new BufContext(); 39 | 40 | callback = cmds.restartBuf.factory(ctx, bufCtx); 41 | }); 42 | 43 | teardown(() => { 44 | sandbox.restore(); 45 | }); 46 | 47 | test("if server is running, stops the server", async () => { 48 | bufCtx.client = {} as unknown as lsp.LanguageClient; 49 | const stopBufExec = sandbox.stub(cmds.stopBuf, "execute"); 50 | 51 | await callback(); 52 | 53 | assert.strictEqual( 54 | stopBufExec.calledOnce, 55 | true, 56 | "stop() should be called once" 57 | ); 58 | }); 59 | 60 | test("if buf not enabled, logs warning and does nothing", async () => { 61 | const configStub = sandbox.stub(config, "get").returns(false); 62 | 63 | await callback(); 64 | 65 | assert.strictEqual( 66 | bufCtx.status, 67 | ServerStatus.SERVER_DISABLED, 68 | "status should be SERVER_DISABLED" 69 | ); 70 | assert.strictEqual(logStub.warn.called, true, "warn should be logged"); 71 | }); 72 | 73 | test("if buf is enabled but not installed, logs error and does nothing", async () => { 74 | const configStub = sandbox.stub(config, "get").returns(true); 75 | 76 | await callback(); 77 | 78 | assert.strictEqual( 79 | bufCtx.status, 80 | ServerStatus.SERVER_NOT_INSTALLED, 81 | "status should be SERVER_NOT_INSTALLED" 82 | ); 83 | assert.strictEqual(logStub.error.called, true, "error should be logged"); 84 | }); 85 | 86 | test("if buf is enabled and installed, starts the server", async () => { 87 | const bufPath = "/usr/local/bin/buf"; 88 | bufCtx.buf = new BufVersion(bufPath, new semver.Range("1.34.14")); 89 | 90 | const startStub = sandbox.stub().resolves(); 91 | 92 | sandbox.stub(lsp, "LanguageClient").returns({ 93 | start: startStub, 94 | createDefaultErrorHandler: () => ({ 95 | error: () => true, 96 | closed: () => true, 97 | }), 98 | clientOptions: {}, 99 | }); 100 | 101 | const configStub = sandbox.stub(config, "get").callsFake((key: string) => { 102 | if (key === "enable") { 103 | return true; 104 | } 105 | if (key === "arguments") { 106 | return []; 107 | } 108 | 109 | return undefined; 110 | }); 111 | 112 | await callback(); 113 | 114 | assert.strictEqual( 115 | startStub.calledOnce, 116 | true, 117 | "start() should be called once" 118 | ); 119 | assert.strictEqual( 120 | bufCtx.status, 121 | ServerStatus.SERVER_RUNNING, 122 | "status should be SERVER_RUNNING" 123 | ); 124 | assert.strictEqual( 125 | logStub.info.calledWith("Buf Language Server started."), 126 | true, 127 | "info should be logged" 128 | ); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /test/unit/commands/update-buf.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | 3 | import * as assert from "assert"; 4 | import * as semver from "semver"; 5 | import * as sinon from "sinon"; 6 | import * as vscode from "vscode"; 7 | import * as cmds from "../../../src/commands"; 8 | import * as installBuf from "../../../src/commands/install-buf"; 9 | import * as github from "../../../src/github"; 10 | import * as util from "../../../src/util"; 11 | 12 | import { CommandCallback } from "../../../src/commands/command"; 13 | import { BufContext } from "../../../src/context"; 14 | import { BufVersion } from "../../../src/version"; 15 | import { MockExtensionContext } from "../../mocks/mock-context"; 16 | import { createStubLog, StubLog } from "../../stubs/stub-log"; 17 | import { createStubVscode, StubVscode } from "../../stubs/stub-vscode"; 18 | 19 | suite("commands.updateBuf", () => { 20 | vscode.window.showInformationMessage("Start all updateBuf tests."); 21 | 22 | let sandbox: sinon.SinonSandbox; 23 | let execFileStub: sinon.SinonStub; 24 | 25 | let stubVscode: StubVscode; 26 | let logStub: StubLog; 27 | let ctx: vscode.ExtensionContext; 28 | let bufCtx: BufContext; 29 | let callback: CommandCallback; 30 | 31 | setup(() => { 32 | sandbox = sinon.createSandbox(); 33 | 34 | execFileStub = sandbox.stub(util, "execFile"); 35 | 36 | stubVscode = createStubVscode(sandbox); 37 | logStub = createStubLog(sandbox); 38 | ctx = MockExtensionContext.new(); 39 | bufCtx = new BufContext(); 40 | 41 | callback = cmds.updateBuf.factory(ctx, bufCtx); 42 | }); 43 | 44 | teardown(() => { 45 | sandbox.restore(); 46 | }); 47 | 48 | test("if buf isn't installed, log error and do nothing", async () => { 49 | bufCtx.buf = undefined; 50 | 51 | await callback(); 52 | 53 | assert.strictEqual(logStub.error.calledOnce, true); 54 | }); 55 | 56 | test("if current version installed, does nothing", async () => { 57 | const bufPath = "/usr/local/bin/buf"; 58 | bufCtx.buf = new BufVersion(bufPath, new semver.Range("1.34.15")); 59 | 60 | const dummyRelease = { 61 | name: "v1.34.15", 62 | tag_name: "v1.34.15", 63 | assets: [], 64 | }; 65 | 66 | const dummyAsset = { 67 | name: "buf-Darwin-arm64", 68 | browser_download_url: "http://dummy", 69 | }; 70 | 71 | sandbox.stub(github, "getRelease").resolves(dummyRelease); 72 | sandbox.stub(github, "findAsset").resolves(dummyAsset); 73 | 74 | const installBufSpy = sandbox.spy(installBuf, "install"); 75 | 76 | await callback(); 77 | 78 | assert.strictEqual( 79 | stubVscode.window.showInformationMessage.calledOnce, 80 | true 81 | ); 82 | assert.strictEqual(installBufSpy.notCalled, true); 83 | }); 84 | 85 | test("if new version available, and we want to update, updates", async () => { 86 | const bufPath = "/usr/local/bin/buf"; 87 | bufCtx.buf = new BufVersion(bufPath, new semver.Range("1.34.14")); 88 | 89 | const dummyRelease = { 90 | name: "v1.34.15", 91 | tag_name: "v1.34.15", 92 | assets: [], 93 | }; 94 | 95 | const dummyAsset = { 96 | name: "buf-Darwin-arm64", 97 | browser_download_url: "http://dummy", 98 | }; 99 | 100 | sandbox.stub(github, "getRelease").resolves(dummyRelease); 101 | sandbox.stub(github, "findAsset").resolves(dummyAsset); 102 | 103 | const installBufStub = sandbox 104 | .stub(installBuf, "install") 105 | .resolves(bufPath); 106 | 107 | sandbox 108 | .stub(BufVersion, "fromPath") 109 | .resolves(new BufVersion(bufPath, new semver.Range("1.34.14"))); 110 | const restartBufStub = sandbox.stub(cmds.restartBuf, "execute").resolves(); 111 | 112 | stubVscode.window.showInformationMessage.resolves("Install cli v1.34.15"); 113 | 114 | await callback(); 115 | 116 | assert.strictEqual( 117 | installBufStub.calledOnce, 118 | true, 119 | "installBuf called once" 120 | ); 121 | assert.strictEqual( 122 | installBufStub.calledWith(ctx, dummyRelease, dummyAsset), 123 | true, 124 | "installBuf called with correct arguments" 125 | ); 126 | assert.strictEqual( 127 | restartBufStub.calledOnce, 128 | true, 129 | "restartBuf called once" 130 | ); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /test/unit/context.test.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import * as sinon from "sinon"; 3 | 4 | import { BufContext, ServerStatus } from "../../src/context"; 5 | import assert from "assert"; 6 | 7 | suite("context", () => { 8 | vscode.window.showInformationMessage("Start all context tests."); 9 | 10 | let sandbox: sinon.SinonSandbox; 11 | 12 | setup(() => { 13 | sandbox = sinon.createSandbox(); 14 | }); 15 | 16 | teardown(() => { 17 | sandbox.restore(); 18 | }); 19 | 20 | suite("BufContext", () => { 21 | test("status is SERVER_STOPPED by default", () => { 22 | const context = new BufContext(); 23 | assert.strictEqual( 24 | context.status, 25 | ServerStatus.SERVER_STOPPED, 26 | "The default status should be SERVER_STOPPED." 27 | ); 28 | }); 29 | 30 | test("changing status emits event", (done) => { 31 | const context = new BufContext(); 32 | let eventFired = false; 33 | const subscription = context.onDidChangeContext(() => { 34 | eventFired = true; 35 | subscription.dispose(); 36 | // Ensure that the updated status is set correctly. 37 | assert.strictEqual( 38 | context.status, 39 | ServerStatus.SERVER_RUNNING, 40 | "The status should change to SERVER_RUNNING." 41 | ); 42 | done(); 43 | }); 44 | // Change status 45 | context.status = ServerStatus.SERVER_RUNNING; 46 | // In case the event isn't fired: 47 | setTimeout(() => { 48 | if (!eventFired) { 49 | subscription.dispose(); 50 | done(new Error("Event was not fired for status change.")); 51 | } 52 | }, 2000); 53 | }); 54 | 55 | test("changing busy also emits event", (done) => { 56 | const context = new BufContext(); 57 | let eventFired = false; 58 | const subscription = context.onDidChangeContext(() => { 59 | eventFired = true; 60 | subscription.dispose(); 61 | // Assert busy changed to true 62 | assert.strictEqual( 63 | context.busy, 64 | true, 65 | "Busy should be true after setting." 66 | ); 67 | done(); 68 | }); 69 | // Change busy property 70 | context.busy = true; 71 | // In case the event isn't fired: 72 | setTimeout(() => { 73 | if (!eventFired) { 74 | subscription.dispose(); 75 | done(new Error("Event was not fired for busy change.")); 76 | } 77 | }, 2000); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /test/unit/extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | suite("Github Test Suite", () => { 4 | vscode.window.showInformationMessage("Start all github tests."); 5 | }); 6 | -------------------------------------------------------------------------------- /test/unit/github.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert"; 2 | import * as sinon from "sinon"; 3 | import * as vscode from "vscode"; 4 | import type * as githubType from "../../src/github"; 5 | 6 | import proxyquire from "proxyquire"; 7 | import { githubReleaseURL } from "../../src/const"; 8 | 9 | suite("github", () => { 10 | vscode.window.showInformationMessage("Start all github tests."); 11 | 12 | let sandbox: sinon.SinonSandbox; 13 | 14 | let github: typeof githubType; 15 | let osPlatformStub: sinon.SinonStub; 16 | let osArchStub: sinon.SinonStub; 17 | 18 | setup(() => { 19 | sandbox = sinon.createSandbox(); 20 | 21 | osPlatformStub = sandbox.stub(); 22 | osArchStub = sandbox.stub(); 23 | 24 | github = proxyquire("../../src/github", { 25 | os: { 26 | platform: osPlatformStub, 27 | arch: osArchStub, 28 | }, 29 | }); 30 | }); 31 | 32 | teardown(() => { 33 | sandbox.restore(); 34 | }); 35 | 36 | suite("getRelease", () => { 37 | test("finds the latest release", async () => { 38 | const dummyRelease = { 39 | name: "v1.0.0", 40 | tag_name: "v1.0.0", 41 | assets: [], 42 | }; 43 | 44 | // Stub the global fetch to simulate a successful response. 45 | const dummyResponse = { 46 | ok: true, 47 | json: async () => dummyRelease, 48 | url: "http://dummy", 49 | status: 200, 50 | statusText: "OK", 51 | }; 52 | const fetchStub = sandbox 53 | .stub(global, "fetch") 54 | .resolves(dummyResponse as Response); 55 | 56 | const release = await github.getRelease(); 57 | assert.deepStrictEqual(release, dummyRelease); 58 | 59 | assert.strictEqual( 60 | fetchStub.calledOnceWith(githubReleaseURL + "latest"), 61 | true 62 | ); 63 | fetchStub.restore(); 64 | }); 65 | 66 | test("finds a specific release", async () => { 67 | const dummyRelease = { 68 | name: "v1.0.0", 69 | tag_name: "v1.0.0", 70 | assets: [], 71 | }; 72 | 73 | // Stub the global fetch to simulate a successful response. 74 | const dummyResponse = { 75 | ok: true, 76 | json: async () => dummyRelease, 77 | url: "http://dummy", 78 | status: 200, 79 | statusText: "OK", 80 | }; 81 | const fetchStub = sandbox 82 | .stub(global, "fetch") 83 | .resolves(dummyResponse as Response); 84 | 85 | const release = await github.getRelease("v1.0.0"); 86 | assert.deepStrictEqual( 87 | release, 88 | dummyRelease, 89 | "Release details should be correct" 90 | ); 91 | 92 | assert.strictEqual( 93 | fetchStub.calledOnceWith(githubReleaseURL + "tags/v1.0.0"), 94 | true, 95 | "Release URL should be correct" 96 | ); 97 | fetchStub.restore(); 98 | }); 99 | }); 100 | 101 | suite("findAsset", () => { 102 | test("finds the asset in a release", async () => { 103 | const tt = [ 104 | { 105 | platform: "darwin", 106 | arch: "arm64", 107 | expected: "buf-Darwin-arm64", 108 | }, 109 | { 110 | platform: "linux", 111 | arch: "aarch64", 112 | expected: "buf-Linux-aarch64", 113 | }, 114 | { 115 | platform: "linux", 116 | arch: "arm64", 117 | expected: "buf-Linux-aarch64", 118 | }, 119 | { 120 | platform: "win32", 121 | arch: "x64", 122 | expected: "buf-Windows-x86_64.exe", 123 | }, 124 | { 125 | platform: "win32", 126 | arch: "x86", 127 | expected: "buf-Windows-x86_64.exe", 128 | }, 129 | ]; 130 | 131 | const dummyRelease = { 132 | name: "v2.0.0", 133 | tag_name: "v2.0.0", 134 | assets: [ 135 | { 136 | name: "buf-Darwin-arm64", 137 | browser_download_url: "http://dummy.com/buf", 138 | }, 139 | { 140 | name: "buf-Linux-x86_64", 141 | browser_download_url: "http://dummy.com/buf", 142 | }, 143 | { 144 | name: "buf-Linux-aarch64", 145 | browser_download_url: "http://dummy.com/buf", 146 | }, 147 | { 148 | name: "buf-Windows-x86_64.exe", 149 | browser_download_url: "http://dummy.com/buf", 150 | }, 151 | ], 152 | }; 153 | 154 | for (const t of tt) { 155 | osPlatformStub.returns(t.platform); 156 | osArchStub.returns(t.arch); 157 | 158 | const asset = await github.findAsset(dummyRelease); 159 | assert.strictEqual(asset.name, t.expected); 160 | } 161 | }); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /test/unit/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /*--------------------------------------------------------- 3 | * Copyright (C) Microsoft Corporation. All rights reserved. 4 | * Licensed under the MIT License. See LICENSE in the project root for license information. 5 | *--------------------------------------------------------*/ 6 | import { glob } from "glob"; 7 | import Mocha from "mocha"; 8 | import * as path from "path"; 9 | export function run(): Promise { 10 | const options: Mocha.MochaOptions = { 11 | grep: process.env.MOCHA_GREP, 12 | ui: "tdd", 13 | }; 14 | if (process.env.MOCHA_TIMEOUT) { 15 | options.timeout = Number(process.env.MOCHA_TIMEOUT); 16 | } 17 | const mocha = new Mocha(options); 18 | 19 | // @types/mocha is outdated 20 | (mocha as any).color(true); 21 | 22 | const testsRoot = path.resolve(__dirname, ".."); 23 | 24 | return new Promise((c, e) => { 25 | glob("unit/**/**.test.js", { cwd: testsRoot }).then((files) => { 26 | // Add files to the test suite 27 | files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f))); 28 | 29 | try { 30 | // Run the mocha test 31 | mocha.run((failures) => { 32 | if (failures > 0) { 33 | e(new Error(`${failures} tests failed.`)); 34 | } else { 35 | c(); 36 | } 37 | }); 38 | } catch (err) { 39 | e(err); 40 | } 41 | }); 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /test/unit/status.test.ts: -------------------------------------------------------------------------------- 1 | import sinon from "sinon"; 2 | import * as assert from "assert"; 3 | import * as vscode from "vscode"; 4 | import * as status from "../../src/status"; 5 | 6 | import { 7 | ExtensionContextPlus, 8 | MockExtensionContext, 9 | } from "../mocks/mock-context"; 10 | import { BufContext } from "../../src/context"; 11 | 12 | suite("status", function () { 13 | vscode.window.showInformationMessage("Start all status tests."); 14 | 15 | let sandbox: sinon.SinonSandbox; 16 | 17 | let ctx: ExtensionContextPlus; 18 | 19 | let statusBarItem: vscode.StatusBarItem; 20 | let createStatusBarItemStub: sinon.SinonStub; 21 | 22 | let bufCtx: BufContext; 23 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 24 | let bufCtxonDidChangeContextSpy: any; 25 | 26 | setup(() => { 27 | sandbox = sinon.createSandbox(); 28 | 29 | statusBarItem = vscode.window.createStatusBarItem(); 30 | createStatusBarItemStub = sandbox 31 | .stub(vscode.window, "createStatusBarItem") 32 | .returns(statusBarItem); 33 | 34 | ctx = MockExtensionContext.new(); 35 | 36 | bufCtx = new BufContext(); 37 | bufCtxonDidChangeContextSpy = sandbox.spy(bufCtx, "onDidChangeContext", [ 38 | "get", 39 | ]); 40 | 41 | status.activate(ctx, bufCtx); 42 | }); 43 | 44 | teardown(() => { 45 | ctx.teardown(); 46 | sandbox.restore(); 47 | status.disposeStatusBar(); 48 | }); 49 | 50 | test("activate sets up subscriptions", function () { 51 | assert.strictEqual(ctx.subscriptions.length, 1); 52 | assert.strictEqual(bufCtxonDidChangeContextSpy.get.calledOnce, true); 53 | }); 54 | 55 | test('activate creates a status bar item with the name "Buf"', () => { 56 | assert.strictEqual(createStatusBarItemStub.callCount, 1); 57 | assert.strictEqual(statusBarItem.name, "Buf"); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /test/unit/version.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | 3 | import * as assert from "assert"; 4 | import * as path from "path"; 5 | import * as semver from "semver"; 6 | import * as sinon from "sinon"; 7 | import * as vscode from "vscode"; 8 | import * as github from "../../src/github"; 9 | import * as util from "../../src/util"; 10 | 11 | import { BufVersion, getBufVersion } from "../../src/version"; 12 | import { 13 | ExtensionContextPlus, 14 | MockExtensionContext, 15 | } from "../mocks/mock-context"; 16 | 17 | suite("version", () => { 18 | vscode.window.showInformationMessage("Start all version tests."); 19 | 20 | let sandbox: sinon.SinonSandbox; 21 | let ctx: ExtensionContextPlus; 22 | 23 | setup(() => { 24 | sandbox = sinon.createSandbox(); 25 | ctx = MockExtensionContext.new(); 26 | }); 27 | 28 | teardown(() => { 29 | sandbox.restore(); 30 | ctx.teardown(); 31 | }); 32 | 33 | suite("BufVersion", () => { 34 | test("Parses simple version successfully", () => { 35 | const version = new BufVersion(".", new semver.Range("1.34.15")); 36 | assert.strictEqual(version.version.raw, "1.34.15"); 37 | }); 38 | 39 | test("fromPath creates BufVersion instance", async () => { 40 | const storagePath = "/path/to/storage"; 41 | sandbox.stub(ctx, "globalStorageUri").value({ 42 | fsPath: storagePath, 43 | }); 44 | const execFileStub = sandbox 45 | .stub(util, "execFile") 46 | .resolves({ stdout: "1.34.15\n", stderr: "" }); 47 | 48 | const bufVersion = await BufVersion.fromPath("/path/to/buf"); 49 | 50 | assert.strictEqual(bufVersion.version.raw, "1.34.15"); 51 | }); 52 | 53 | test("when buf from config, fromPath sets source as CONFIG", async () => { 54 | const bufPath = "/path/to/buf"; 55 | sandbox.stub(vscode.workspace, "getConfiguration").returns({ 56 | get: function (key: string) { 57 | if (key === "path") { 58 | return bufPath; 59 | } 60 | 61 | return undefined; 62 | }, 63 | } as unknown as vscode.WorkspaceConfiguration); 64 | 65 | sandbox 66 | .stub(util, "execFile") 67 | .resolves({ stdout: "1.34.15\n", stderr: "" }); 68 | 69 | const bufVersion = await BufVersion.fromPath(bufPath); 70 | }); 71 | 72 | test("when buf from extension, fromPath sets source as EXTENSION", async () => { 73 | const storagePath = "/path/to/storage"; 74 | const bufPath = path.join(storagePath, "/path/to/buf"); 75 | sandbox.stub(vscode.workspace, "getConfiguration").returns({ 76 | get: function (key: string) { 77 | if (key === "path") { 78 | return undefined; 79 | } 80 | 81 | return undefined; 82 | }, 83 | } as unknown as vscode.WorkspaceConfiguration); 84 | 85 | sandbox.stub(ctx, "globalStorageUri").value({ 86 | fsPath: storagePath, 87 | }); 88 | 89 | sandbox 90 | .stub(util, "execFile") 91 | .resolves({ stdout: "1.34.15\n", stderr: "" }); 92 | 93 | const bufVersion = await BufVersion.fromPath(bufPath); 94 | }); 95 | 96 | test("when buf from path, fromPath sets source as PATH", async () => { 97 | const storagePath = "/path/to/storage"; 98 | const bufPath = "/path/to/buf"; 99 | sandbox.stub(vscode.workspace, "getConfiguration").returns({ 100 | get: function (key: string) { 101 | if (key === "path") { 102 | return undefined; 103 | } 104 | 105 | return undefined; 106 | }, 107 | } as unknown as vscode.WorkspaceConfiguration); 108 | 109 | sandbox.stub(ctx, "globalStorageUri").value({ 110 | fsPath: storagePath, 111 | }); 112 | 113 | sandbox 114 | .stub(util, "execFile") 115 | .resolves({ stdout: "1.34.15\n", stderr: "" }); 116 | 117 | const bufVersion = await BufVersion.fromPath(bufPath); 118 | }); 119 | 120 | test("hasUpgrade detects upgrade", async () => { 121 | const release = { 122 | tag_name: "v1.35.0", 123 | name: "Release 1.35.0", 124 | } as github.Release; 125 | const bufVersion = new BufVersion(".", new semver.Range("1.34.15")); 126 | 127 | const result = await bufVersion.hasUpgrade(release); 128 | assert.strictEqual(result.upgrade, true); 129 | }); 130 | 131 | test("hasUpgrade detects no upgrade", async () => { 132 | const release = { 133 | tag_name: "v1.34.15", 134 | name: "Release 1.34.15", 135 | } as github.Release; 136 | const bufVersion = new BufVersion(".", new semver.Range("1.34.15")); 137 | 138 | const result = await bufVersion.hasUpgrade(release); 139 | assert.strictEqual(result.upgrade, false); 140 | }); 141 | }); 142 | 143 | suite("getBufVersion", () => { 144 | test("returns correct version", async () => { 145 | const execFileStub = sandbox 146 | .stub(util, "execFile") 147 | .resolves({ stdout: "1.34.15\n", stderr: "" }); 148 | 149 | const version = await getBufVersion("/path/to/buf"); 150 | assert.strictEqual(version.raw, "1.34.15"); 151 | }); 152 | 153 | test("throws error on stderr", async () => { 154 | sandbox.stub(util, "execFile").resolves({ stdout: "", stderr: "error" }); 155 | 156 | await assert.rejects(async () => { 157 | await getBufVersion("/path/to/buf"); 158 | }, /Error getting version of '\/path\/to\/buf'! error/); 159 | }); 160 | 161 | test("throws error on empty stdout", async () => { 162 | sandbox.stub(util, "execFile").resolves({ stdout: " \n", stderr: "" }); 163 | 164 | await assert.rejects(async () => { 165 | await getBufVersion("/path/to/buf"); 166 | }, /Unable to determine version of '\/path\/to\/buf'!/); 167 | }); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "Node16", 4 | "target": "ES2022", 5 | "outDir": "out", 6 | "lib": ["ES2022"], 7 | "skipLibCheck": true, 8 | "sourceMap": true, 9 | "strict": true /* enable all strict type-checking options */, 10 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 11 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 12 | "noUnusedParameters": true /* Report errors on unused parameters. */, 13 | "resolveJsonModule": true 14 | } 15 | } 16 | --------------------------------------------------------------------------------