├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .eslintrc.js ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── marketplace.yml │ └── release.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.json ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── icon.png ├── package-lock.json ├── package.json ├── resources └── report_issue_template.md ├── src ├── ConfigurationService.ts ├── LanguageServer.ts ├── LanguageServerErrorHandler.ts ├── LoggingService.ts ├── StatusBar.ts ├── commands.ts ├── constants.ts ├── extension.ts ├── processes.ts └── utils.ts ├── tsconfig.json └── webpack.config.js /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.18 2 | RUN apk update && apk add git php82 nodejs npm php82-openssl php82-json php82-phar php82-mbstring libxslt openssh 3 | RUN ln -s /usr/bin/php82 /usr/bin/php 4 | RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php'); if (hash_file('sha384', 'composer-setup.php') === 'dac665fdc30fdd8ec78b38b9800061b4150413ff2e3b6f88543c636f7cd84f6db9189d43a81e5503cda447da73c7e5b6') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;" && php composer-setup.php && mv composer.phar /usr/bin/composer && php -r "unlink('composer-setup.php');" 5 | ENV COMPOSER_ALLOW_SUPERUSER=1 -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node 3 | { 4 | "name": "Node.js,TypeScript,and PHP", 5 | "build": { 6 | // Path is relative to the devcontainer.json file. 7 | "dockerfile": "Dockerfile" 8 | }, 9 | "customizations": { 10 | "vscode": { 11 | "extensions": [ 12 | "amodio.tsl-problem-matcher" 13 | ] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "parser": "@typescript-eslint/parser", 7 | "parserOptions": { 8 | "project": "tsconfig.json", 9 | "sourceType": "module" 10 | }, 11 | "plugins": [ 12 | "eslint-plugin-import", 13 | "eslint-plugin-jsdoc", 14 | "@typescript-eslint", 15 | "prettier" 16 | ], 17 | "root": true, 18 | "rules": { 19 | 'prettier/prettier': [ 20 | 'error', 21 | { 22 | trailingComma: 'es5', 23 | singleQuote: true, 24 | printWidth: 80, 25 | // below line only for windows users facing CLRF and eslint/prettier error 26 | // non windows users feel free to delete it 27 | endOfLine: 'auto' 28 | }, 29 | ], 30 | "@typescript-eslint/consistent-type-definitions": "error", 31 | "@typescript-eslint/dot-notation": "off", 32 | "@typescript-eslint/explicit-member-accessibility": [ 33 | "off", 34 | { 35 | "accessibility": "explicit" 36 | } 37 | ], 38 | "@typescript-eslint/indent": "error", 39 | "@typescript-eslint/member-delimiter-style": [ 40 | "error", 41 | { 42 | "multiline": { 43 | "delimiter": "semi", 44 | "requireLast": true 45 | }, 46 | "singleline": { 47 | "delimiter": "semi", 48 | "requireLast": false 49 | } 50 | } 51 | ], 52 | "@typescript-eslint/member-ordering": "error", 53 | "@typescript-eslint/naming-convention": [ 54 | "error", 55 | { 56 | "selector": "variable", 57 | "format": [ 58 | "camelCase", 59 | "UPPER_CASE" 60 | ], 61 | "leadingUnderscore": "forbid", 62 | "trailingUnderscore": "forbid" 63 | } 64 | ], 65 | "@typescript-eslint/no-empty-function": "off", 66 | "@typescript-eslint/no-empty-interface": "error", 67 | "@typescript-eslint/no-inferrable-types": [ 68 | "error", 69 | { 70 | "ignoreParameters": true 71 | } 72 | ], 73 | "@typescript-eslint/no-misused-new": "error", 74 | "@typescript-eslint/no-non-null-assertion": "error", 75 | "@typescript-eslint/no-shadow": [ 76 | "error", 77 | { 78 | "hoist": "all" 79 | } 80 | ], 81 | "@typescript-eslint/no-unused-expressions": "error", 82 | "@typescript-eslint/no-use-before-define": "error", 83 | "@typescript-eslint/prefer-function-type": "error", 84 | "@typescript-eslint/quotes": [ 85 | "error", 86 | "single" 87 | ], 88 | "@typescript-eslint/semi": [ 89 | "error", 90 | "always" 91 | ], 92 | "@typescript-eslint/type-annotation-spacing": "error", 93 | "@typescript-eslint/unified-signatures": "error", 94 | "arrow-body-style": "error", 95 | "brace-style": [ 96 | "error", 97 | "1tbs" 98 | ], 99 | "constructor-super": "error", 100 | "curly": "error", 101 | "dot-notation": "off", 102 | "eol-last": "error", 103 | "eqeqeq": [ 104 | "error", 105 | "smart" 106 | ], 107 | "guard-for-in": "error", 108 | "id-denylist": "off", 109 | "id-match": "off", 110 | "import/no-deprecated": "warn", 111 | "indent": "off", 112 | "jsdoc/no-types": "error", 113 | "max-len": [ 114 | "error", 115 | { 116 | "code": 140 117 | } 118 | ], 119 | "no-bitwise": "error", 120 | "no-caller": "error", 121 | "no-console": [ 122 | "error", 123 | { 124 | "allow": [ 125 | "log", 126 | "warn", 127 | "dir", 128 | "timeLog", 129 | "assert", 130 | "clear", 131 | "count", 132 | "countReset", 133 | "group", 134 | "groupEnd", 135 | "table", 136 | "dirxml", 137 | "error", 138 | "groupCollapsed", 139 | "Console", 140 | "profile", 141 | "profileEnd", 142 | "timeStamp", 143 | "context", 144 | "createTask" 145 | ] 146 | } 147 | ], 148 | "no-debugger": "error", 149 | "no-empty": "off", 150 | "no-empty-function": "off", 151 | "no-eval": "error", 152 | "no-fallthrough": "error", 153 | "no-new-wrappers": "error", 154 | "no-shadow": "off", 155 | "no-throw-literal": "error", 156 | "no-trailing-spaces": "error", 157 | "no-undef-init": "error", 158 | "no-underscore-dangle": "off", 159 | "no-unused-expressions": "off", 160 | "no-unused-labels": "error", 161 | "no-use-before-define": "off", 162 | "no-var": "error", 163 | "prefer-const": "error", 164 | "quotes": "off", 165 | "radix": "error", 166 | "semi": "off", 167 | "spaced-comment": [ 168 | "error", 169 | "always", 170 | { 171 | "markers": [ 172 | "/" 173 | ] 174 | } 175 | ] 176 | } 177 | }; 178 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: weekly 7 | - package-ecosystem: github-actions 8 | directory: / 9 | schedule: 10 | interval: monthly 11 | 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | pull_request: 4 | push: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: actions/setup-node@v3 12 | with: 13 | node-version: '18' 14 | - run: npm install 15 | - run: npm run lint 16 | - run: npm run package 17 | -------------------------------------------------------------------------------- /.github/workflows/marketplace.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - "*" 5 | 6 | name: Deploy Extension 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 18 15 | - run: npm install 16 | - run: npm run lint 17 | - name: Publish to Open VSX Registry 18 | uses: HaaLeo/publish-vscode-extension@v1 19 | with: 20 | pat: ${{ secrets.OPEN_VSX }} 21 | - name: Publish to Visual Studio Marketplace 22 | uses: HaaLeo/publish-vscode-extension@v1 23 | with: 24 | pat: ${{ secrets.VSCODE_MARKEPLACE }} 25 | registryUrl: https://marketplace.visualstudio.com -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - "*" 8 | 9 | jobs: 10 | tagged-release: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: '18' 18 | - run: npm install 19 | - run: npm run lint 20 | - run: npm run package 21 | - id: get_version 22 | uses: battila7/get-version-action@v2 23 | - name: Get Changelog Entry 24 | id: changelog_reader 25 | uses: mindsers/changelog-reader-action@v2 26 | with: 27 | version: ${{ steps.get_version.outputs.version }} 28 | - uses: softprops/action-gh-release@v1 29 | with: 30 | body: ${{ steps.changelog_reader.outputs.changes }} 31 | tag_name: ${{ steps.get_version.outputs.version }} 32 | files: psalm-vscode-plugin-${{ steps.get_version.outputs.version }}.vsix -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | node_modules/ 4 | node_modules 5 | .DS_Store 6 | *.vsix -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.17 -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 4, 4 | "semi": true, 5 | "singleQuote": true 6 | } -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "amodio.tsl-problem-matcher" 4 | ] 5 | } -------------------------------------------------------------------------------- /.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 | "--extensionDevelopmentPath=${workspaceFolder}", 14 | "--disable-extensions" 15 | ], 16 | "outFiles": ["${workspaceFolder}/dist/**/*.js"], 17 | "preLaunchTask": "${defaultBuildTask}" 18 | }, 19 | { 20 | "name": "Extension Tests", 21 | "type": "extensionHost", 22 | "request": "launch", 23 | "args": [ 24 | "--extensionDevelopmentPath=${workspaceFolder}", 25 | "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" 26 | ], 27 | "outFiles": [ 28 | "${workspaceFolder}/out/**/*.js", 29 | "${workspaceFolder}/dist/**/*.js" 30 | ], 31 | "preLaunchTask": "tasks: watch-tests" 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "npm.exclude": ["**/src"], 3 | "typescript.tsdk": "node_modules\\typescript\\lib", 4 | // Set the default 5 | "editor.formatOnSave": true, 6 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 7 | "prettier.prettierPath": "node_modules/prettier", 8 | // Enable per-language 9 | "[javascript]": { 10 | "editor.formatOnSave": false, 11 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 12 | }, 13 | "[typescript]": { 14 | "editor.formatOnSave": false, 15 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 16 | }, 17 | "eslint.format.enable": true, 18 | "editor.codeActionsOnSave": { 19 | "source.fixAll.eslint": "explicit" 20 | }, 21 | "psalmLanguageServer.trace.server": "message", 22 | "files.exclude": { 23 | "out": false, // set this to true to hide the "out" folder with the compiled JS files 24 | "dist": false // set this to true to hide the "dist" folder with the compiled JS files 25 | }, 26 | "search.exclude": { 27 | "out": true, // set this to false to include "out" folder in search results 28 | "dist": true // set this to false to include "dist" folder in search results 29 | }, 30 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 31 | "typescript.tsc.autoDetect": "off", 32 | "intelephense.files.exclude": ["**"], 33 | "intelephense.references.exclude": ["**"], 34 | "intelephense.rename.exclude": ["**"], 35 | "intelephense.files.associations": [] 36 | } 37 | -------------------------------------------------------------------------------- /.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 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$ts-webpack-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never", 13 | "group": "watchers" 14 | }, 15 | "group": { 16 | "kind": "build", 17 | "isDefault": true 18 | } 19 | }, 20 | { 21 | "type": "npm", 22 | "script": "watch-tests", 23 | "problemMatcher": "$tsc-watch", 24 | "isBackground": true, 25 | "presentation": { 26 | "reveal": "never", 27 | "group": "watchers" 28 | }, 29 | "group": "build" 30 | }, 31 | { 32 | "label": "tasks: watch-tests", 33 | "dependsOn": ["npm: watch", "npm: watch-tests"], 34 | "problemMatcher": [] 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .github/** 2 | .vscode/** 3 | .vscode-test/** 4 | out/** 5 | node_modules/** 6 | src/** 7 | .gitignore 8 | .yarnrc 9 | webpack.config.js 10 | vsc-extension-quickstart.md 11 | **/tsconfig.json 12 | **/.eslintrc.json 13 | **/*.map 14 | **/*.ts 15 | .nvmrc 16 | .devcontainer/** 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ### Changes 11 | 12 | - Support v4.13.0 of code-server which supports Code v1.78.2 13 | - Cleanup old dependencies and remove need for webpack 14 | 15 | ## [2.7.0] - 2022-07-25 16 | 17 | ### Changed 18 | 19 | - Restart language server on project switching when using workspaces (#184) [@tncrazvan] 20 | - Prevent focusing the output tab (#174) [@jarstelfox] 21 | 22 | ## [2.6.0] - 2022-02-01 23 | 24 | ### Added 25 | 26 | - Option to forcefully set the Psalm Version instead of auto detecting 27 | 28 | ### Changed 29 | 30 | - Cleaned up logger outputs 31 | - Added watching of the composer.lock file (ignored on server for now) 32 | 33 | ## [2.5.0] - 2022-01-03 34 | 35 | ### Added 36 | 37 | - Option to disable autocomplete on methods and properties 38 | 39 | ### Changed 40 | 41 | - Updated LoggingService to support new replace method 42 | - Updated underlying libraries 43 | 44 | ## [2.4.0] - 2021-11-15 45 | 46 | ### Added 47 | 48 | - Allow setting arbitrary language server parameters (#123) [@Nadyita] 49 | 50 | ## [2.3.0] - 2021-09-17 51 | 52 | ### Added 53 | 54 | - New "Report Issue" command (#93) 55 | - New "Show Output" command 56 | - Extend OutputChannel to be able to buffer output internally for error reporting (up to 1000 lines) 57 | - Add button to report server crashes 58 | - Abstract out Max Restart Count into a setting `psalm.maxRestartCount` 59 | 60 | ## [2.2.3] - 2021-09-17 61 | 62 | ### Fixed 63 | 64 | - Could not resolve path to config on windows (#84) [@glen-84] 65 | 66 | ## [2.2.2] - 2021-08-20 67 | 68 | ### Added 69 | 70 | - Adjust how changelog is created to that releases can be automatically created 71 | 72 | ## [2.2.1] - 2021-08-20 73 | 74 | ### Added 75 | 76 | - Set `untrustedWorkspaces.supported` to `false` in `capabilities`. Reasoning: Since this runs Psalm, and Psalm can be configured to execute code on your computer, you should avoid opening untrusted projects while using this plugin 77 | 78 | ## [2.2.0] - 2021-08-19 79 | 80 | ### Added 81 | 82 | - Add better tracing/debug/logging 83 | 84 | ### Changed 85 | 86 | - Consolidates OUTPUT log window into one view instead of two 87 | 88 | ### Deprecated 89 | 90 | - Deprecates `enableDebugLog` in favor of split settings `trace.server`, `logLevel` and `enableVerbose`. See settings for more information 91 | 92 | ## [2.1.0] - 2021-08-14 93 | 94 | ### Fixed 95 | 96 | - Fixes "Support for absolute paths for Psalm Client Script Path and Psalm Script Path" (#71) [@ thomasbley] 97 | 98 | ### Deprecated 99 | 100 | - Deprecates `psalmClientScriptPath` setting in favor of `psalmScriptPath` since `psalmClientScriptPath` fell back to `psalmScriptPath` anyways 101 | 102 | ## [2.0.6] - 2021-08-13 103 | 104 | ### Fixed 105 | 106 | - Fixes "Set vscode minimum version to 1.57.1" (#77) [@thomasbley] 107 | 108 | ## [2.0.5] - 2021-08-03 109 | 110 | ### Fixed 111 | 112 | - Fixes fix "--use-ini-defaults" option feature (typo?) (#70) [@yaegassy] 113 | 114 | ## [2.0.4] - 2021-08-02 115 | 116 | ### Added 117 | 118 | - Mock StreamWriter so that logging of output from language server actually logs in verbose mode 119 | - Add setting to "Index" workspace (Just calls same method as in #30 for now) 120 | - Add support for onSave, onOpen, onClose (see vimeo/psalm#6010) 121 | - Add support for workspace/didChangeWatchedFiles (see vimeo/psalm#6014) Fixes #32 122 | - Run prettier on save (vscode for this project only) 123 | - Bundle Extension using webpack (https://code.visualstudio.com/api/working-with-extensions/bundling-extension) 124 | 125 | ### Fixed 126 | 127 | - Fix settings pane to be more graphical 128 | - Fix #30 to not blow up because of vimeo/psalm#6007 129 | 130 | ## [1.2.1] - 2020-04-21 131 | 132 | ### Added 133 | 134 | - Added help links to error and warnings codes using new command line options from the Psalm Language Server. 135 | - Added new configuration options. 136 | - Added status to the VSCode status bar. 137 | 138 | ## [0.1.0] - 2018-10-19 139 | 140 | ### Added 141 | 142 | - First version released 143 | 144 | ## [0.5.0] - 2018-11-19 145 | 146 | ### Added 147 | 148 | - Windows support added 149 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Psalm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Psalm VScode Plugin 2 | 3 | [![build](https://github.com/psalm/psalm-vscode-plugin/actions/workflows/ci.yml/badge.svg)](https://github.com/psalm/psalm-vscode-plugin/actions/workflows/ci.yml) 4 | [![downloads](https://img.shields.io/visual-studio-marketplace/d/getpsalm.psalm-vscode-plugin)](https://marketplace.visualstudio.com/items?itemName=getpsalm.psalm-vscode-plugin) 5 | [![installs](https://img.shields.io/visual-studio-marketplace/i/getpsalm.psalm-vscode-plugin)](https://marketplace.visualstudio.com/items?itemName=getpsalm.psalm-vscode-plugin) 6 | 7 | [Psalm](https://getpsalm.org) is a static analysis tool for finding errors in PHP applications. 8 | 9 | ## Installation 10 | 11 | ### VSCode Market Place 12 | 13 | Install through VS Code extensions. Search for `Psalm (PHP Static Analysis Linting Machine)` 14 | 15 | [Psalm (PHP Static Analysis Linting Machine)](https://marketplace.visualstudio.com/items?itemName=getpsalm.psalm-vscode-plugin) 16 | 17 | Can also be installed in VS Code: Launch VS Code Quick Open (Ctrl+P), paste the following command, and press enter. 18 | 19 | ``` 20 | ext install getpsalm.psalm-vscode-plugin 21 | ``` 22 | 23 | ### Open VSX Registry 24 | 25 | Install through VS Code extensions. Search for `Psalm (PHP Static Analysis Linting Machine)` 26 | 27 | [Psalm (PHP Static Analysis Linting Machine)](https://open-vsx.org/extension/getpsalm/psalm-vscode-plugin) 28 | 29 | ## Features 30 | 31 | - Runs [Psalm's analysis](https://getpsalm.org) when opening and saving files using the Language Server Protocol for communication. 32 | 33 | ## Contributing 34 | 35 | You can build and test locally in Visual Studio by going to "Run and Debug" and clicking "Launch Extension" 36 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psalm/psalm-vscode-plugin/c03b6f87445991389eb268cb49452b446a76d56a/icon.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "psalm-vscode-plugin", 3 | "displayName": "Psalm (PHP Static Analysis Linting Machine)", 4 | "description": "VS Code Plugin for Psalm", 5 | "author": "Matthew Brown", 6 | "contributors": [ 7 | { 8 | "name": "Andrew Nagy" 9 | }, 10 | { 11 | "name": "Anthony Rainer" 12 | } 13 | ], 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/psalm/psalm-vscode-plugin/issues" 17 | }, 18 | "version": "2.7.0", 19 | "publisher": "getpsalm", 20 | "categories": [ 21 | "Linters", 22 | "Programming Languages" 23 | ], 24 | "icon": "icon.png", 25 | "galleryBanner": { 26 | "color": "#582a24", 27 | "theme": "dark" 28 | }, 29 | "repository": { 30 | "type": "git", 31 | "url": "https://github.com/psalm/psalm-vscode-plugin" 32 | }, 33 | "engines": { 34 | "vscode": "^1.80.2" 35 | }, 36 | "activationEvents": [ 37 | "onLanguage:php", 38 | "workspaceContains:**/*.php", 39 | "workspaceContains:**/psalm.@(xml|xml.dist)" 40 | ], 41 | "main": "./dist/extension.js", 42 | "scripts": { 43 | "vscode:prepublish": "npm run compile", 44 | "compile": "webpack", 45 | "watch": "webpack --watch", 46 | "package": "webpack --mode production --devtool hidden-source-map", 47 | "compile-tests": "tsc -p . --outDir out", 48 | "watch-tests": "tsc -p . -w --outDir out", 49 | "vsce:package": "vsce package", 50 | "vsce:publish": "vsce publish", 51 | "lint": "eslint src --ext ts && tsc --noEmit" 52 | }, 53 | "capabilities": { 54 | "untrustedWorkspaces": { 55 | "supported": false, 56 | "description": "Since this runs Psalm, and Psalm can be configured to execute code on your computer, you should avoid opening untrusted projects while using this plugin" 57 | } 58 | }, 59 | "extensionKind": [ 60 | "workspace" 61 | ], 62 | "contributes": { 63 | "configuration": { 64 | "type": "object", 65 | "title": "PHP - Psalm Analyzer", 66 | "properties": { 67 | "psalm.phpExecutablePath": { 68 | "type": "string", 69 | "default": null, 70 | "description": "Optional, defaults to searching for \"php\". The path to a PHP 7.0+ executable to use to execute the Psalm server. The PHP 7.0+ installation should preferably include and enable the PHP module `pcntl`. (Modifying requires VSCode reload)" 71 | }, 72 | "psalm.phpExecutableArgs": { 73 | "type": "array", 74 | "items": { 75 | "type": "string" 76 | }, 77 | "default": [ 78 | "-dxdebug.remote_autostart=0", 79 | "-dxdebug.remote_enable=0", 80 | "-dxdebug_profiler_enable=0" 81 | ], 82 | "description": "Optional (Advanced), default is '-dxdebug.remote_autostart=0 -dxdebug.remote_enable=0 -dxdebug_profiler_enable=0'. Additional PHP executable CLI arguments to use. (Modifying requires VSCode reload)" 83 | }, 84 | "psalm.psalmVersion": { 85 | "type": "string", 86 | "default": null, 87 | "description": "Optional (Advanced). If provided, this overrides the Psalm version detection (Modifying requires VSCode reload)" 88 | }, 89 | "psalm.psalmScriptPath": { 90 | "type": "string", 91 | "default": null, 92 | "description": "Optional (Advanced). If provided, this overrides the Psalm script to use, e.g. vendor/bin/psalm-language-server. (Modifying requires VSCode reload)" 93 | }, 94 | "psalm.psalmScriptArgs": { 95 | "type": "array", 96 | "items": { 97 | "type": "string" 98 | }, 99 | "default": [], 100 | "description": "Optional (Advanced). Additional arguments to the Psalm language server. (Modifying requires VSCode reload)" 101 | }, 102 | "psalm.psalmClientScriptPath": { 103 | "type": "string", 104 | "default": null, 105 | "description": "Optional (Advanced). If provided, this overrides the Psalm script to use, e.g. vendor/bin/psalm. (Modifying requires VSCode reload)", 106 | "markdownDeprecationMessage": "**Deprecated**: Please use `#psalm.psalmScriptPath#` instead.", 107 | "deprecationMessage": "Deprecated: Please use psalm.psalmScriptPath instead." 108 | }, 109 | "psalm.enableUseIniDefaults": { 110 | "type": "boolean", 111 | "default": false, 112 | "description": "Enable this to use PHP-provided ini defaults for memory and error display. (Modifying requires restart)" 113 | }, 114 | "psalm.enableDebugLog": { 115 | "type": "boolean", 116 | "default": false, 117 | "description": "Enable this to print messages to the debug console when developing or debugging this VS Code extension. (Modifying requires VSCode reload)", 118 | "deprecationMessage": "Deprecated: Please use psalm.enableVerbose, psalm.logLevel or psalm.trace.server instead." 119 | }, 120 | "psalm.enableVerbose": { 121 | "type": "boolean", 122 | "default": false, 123 | "description": "Enable --verbose mode on the Psalm Language Server (Modifying requires VSCode reload)" 124 | }, 125 | "psalm.logLevel": { 126 | "type": "string", 127 | "enum": [ 128 | "NONE", 129 | "ERROR", 130 | "WARN", 131 | "INFO", 132 | "DEBUG", 133 | "TRACE" 134 | ], 135 | "default": "INFO", 136 | "description": "Traces the communication between VSCode and the Psalm language server.", 137 | "scope": "window" 138 | }, 139 | "psalm.trace.server": { 140 | "type": "string", 141 | "enum": [ 142 | "off", 143 | "messages", 144 | "verbose" 145 | ], 146 | "default": "off", 147 | "description": "Traces the communication between VSCode and the Psalm language server.", 148 | "scope": "window" 149 | }, 150 | "psalm.analyzedFileExtensions": { 151 | "type": "array", 152 | "default": [ 153 | { 154 | "scheme": "file", 155 | "language": "php" 156 | }, 157 | { 158 | "scheme": "untitled", 159 | "language": "php" 160 | } 161 | ], 162 | "description": "A list of file extensions to request Psalm to analyze. By default, this only includes 'php' (Modifying requires VSCode reload)" 163 | }, 164 | "psalm.unusedVariableDetection": { 165 | "type": "boolean", 166 | "default": false, 167 | "description": "Enable this to enable unused variable and parameter detection" 168 | }, 169 | "psalm.connectToServerWithTcp": { 170 | "type": "boolean", 171 | "default": false, 172 | "description": "If this is set to true, this VSCode extension will use TCP instead of the default STDIO to communicate with the Psalm language server. (Modifying requires VSCode reload)" 173 | }, 174 | "psalm.disableAutoComplete": { 175 | "type": "boolean", 176 | "default": false, 177 | "description": "Enable to disable autocomplete on methods and properties (Modifying requires VSCode reload)" 178 | }, 179 | "psalm.configPaths": { 180 | "type": "array", 181 | "items": { 182 | "type": "string" 183 | }, 184 | "default": [ 185 | "psalm.xml", 186 | "psalm.xml.dist" 187 | ], 188 | "description": "A list of files to checkup for psalm configuration (relative to the workspace directory)" 189 | }, 190 | "psalm.hideStatusMessageWhenRunning": { 191 | "type": "boolean", 192 | "default": true, 193 | "description": "This will hide the Psalm status from the status bar when it is started and running. This is useful to clear up a cluttered status bar." 194 | }, 195 | "psalm.maxRestartCount": { 196 | "type": "number", 197 | "default": 5, 198 | "description": "The number of times the Language Server is allowed to crash and restart before it will no longer try to restart (Modifying requires VSCode reload)" 199 | } 200 | } 201 | }, 202 | "commands": [ 203 | { 204 | "command": "psalm.restartPsalmServer", 205 | "title": "Restart Psalm Language server", 206 | "category": "Psalm" 207 | }, 208 | { 209 | "command": "psalm.analyzeWorkSpace", 210 | "title": "Analyze Workspace", 211 | "category": "Psalm" 212 | }, 213 | { 214 | "command": "psalm.reportIssue", 215 | "title": "Report Issue", 216 | "category": "Psalm" 217 | }, 218 | { 219 | "command": "psalm.showOutput", 220 | "title": "Show Output", 221 | "category": "Psalm" 222 | } 223 | ], 224 | "menus": { 225 | "commandPalette": [ 226 | { 227 | "command": "psalm.restartPsalmServer" 228 | }, 229 | { 230 | "command": "psalm.analyzeWorkSpace" 231 | }, 232 | { 233 | "command": "psalm.reportIssue" 234 | }, 235 | { 236 | "command": "psalm.showOutput" 237 | } 238 | ] 239 | } 240 | }, 241 | "devDependencies": { 242 | "@types/node": "16.11.68", 243 | "@types/semver": "^7.5.2", 244 | "@types/vscode": "1.80.0", 245 | "@types/which": "^3.0.0", 246 | "@typescript-eslint/eslint-plugin": "^6.7.2", 247 | "@typescript-eslint/parser": "^6.7.2", 248 | "@vscode/vsce": "^2.21.0", 249 | "eslint": "^8.49.0", 250 | "eslint-config-prettier": "^9.0.0", 251 | "eslint-plugin-import": "^2.28.1", 252 | "eslint-plugin-jsdoc": "^46.8.1", 253 | "prettier": "^3.0.3", 254 | "ts-loader": "^9.4.4", 255 | "typescript": "^5.2.2", 256 | "webpack": "^5.88.2", 257 | "webpack-cli": "^5.1.4", 258 | "eslint-plugin-prettier": "^5.0.0" 259 | }, 260 | "dependencies": { 261 | "@types/fs-extra": "^11.0.2", 262 | "fs-extra": "^11.1.1", 263 | "promisify-child-process": "^4.1.2", 264 | "semver": "^7.5.4", 265 | "vscode-languageclient": "^8.1.0", 266 | "which": "^4.0.0" 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /resources/report_issue_template.md: -------------------------------------------------------------------------------- 1 | 4 | 5 | # Behaviour 6 | 7 | ## Expected 8 | 9 | XXX 10 | 11 | ## Actual 12 | 13 | XXX 14 | 15 | ## Steps to reproduce: 16 | 17 | [**NOTE**: Self-contained, minimal reproducing code samples are **extremely** helpful and will expedite addressing your issue] 18 | 19 | 1. 20 | 21 | 25 | 26 | # Diagnostic data 27 | 28 | - PHP version: {0} 29 | - Psalm version: {1} 30 | 31 |
32 | 33 | "Psalm Language Server" channel in the OUTPUT panel (Last 1000 lines) 34 | 35 |

36 | 37 | 38 | 39 | ``` 40 | {2} 41 | ``` 42 | 43 |

44 |
45 | 46 |
47 | 48 | User Settings 49 | 50 |

51 | 52 | ``` 53 | {3} 54 | ``` 55 | 56 |

57 |
58 | -------------------------------------------------------------------------------- /src/ConfigurationService.ts: -------------------------------------------------------------------------------- 1 | import { workspace, WorkspaceConfiguration } from 'vscode'; 2 | import which from 'which'; 3 | import { join } from 'path'; 4 | import { DocumentSelector, integer } from 'vscode-languageserver-protocol'; 5 | import { showOpenSettingsPrompt } from './utils'; 6 | import { LogLevel } from './LoggingService'; 7 | 8 | interface Config { 9 | phpExecutablePath?: string; 10 | phpExecutableArgs?: string[]; 11 | psalmVersion?: string; 12 | psalmScriptPath?: string; 13 | psalmScriptArgs?: string[]; 14 | disableAutoComplete: boolean; 15 | maxRestartCount: integer; 16 | unusedVariableDetection: boolean; 17 | enableVerbose: boolean; 18 | connectToServerWithTcp: boolean; 19 | enableUseIniDefaults: boolean; 20 | logLevel: LogLevel; 21 | analyzedFileExtensions?: string[] | DocumentSelector; 22 | configPaths?: string[]; 23 | hideStatusMessageWhenRunning: boolean; 24 | psalmServerScriptPath?: string; 25 | } 26 | 27 | export class ConfigurationService { 28 | private config: Config = { 29 | maxRestartCount: 5, 30 | disableAutoComplete: false, 31 | unusedVariableDetection: false, 32 | enableVerbose: false, 33 | connectToServerWithTcp: false, 34 | enableUseIniDefaults: false, 35 | hideStatusMessageWhenRunning: false, 36 | logLevel: 'INFO', 37 | }; 38 | 39 | public constructor() {} 40 | 41 | public async init() { 42 | const workspaceConfiguration: WorkspaceConfiguration = 43 | workspace.getConfiguration('psalm'); 44 | 45 | // Work around until types are updated 46 | let whichPHP: Config['phpExecutablePath']; 47 | try { 48 | whichPHP = await which('php'); 49 | } catch (err) {} 50 | 51 | // PHP Executable Path or default to which 52 | this.config.phpExecutablePath = workspaceConfiguration.get( 53 | 'phpExecutablePath', 54 | whichPHP 55 | ); 56 | 57 | // The Executable Arguments 58 | this.config.phpExecutableArgs = workspaceConfiguration.get( 59 | 'phpExecutableArgs', 60 | [ 61 | '-dxdebug.remote_autostart=0', 62 | '-dxdebug.remote_enable=0', 63 | '-dxdebug_profiler_enable=0', 64 | ] 65 | ); 66 | 67 | this.config.psalmVersion = 68 | workspaceConfiguration.get('psalmVersion'); 69 | 70 | this.config.psalmScriptPath = workspaceConfiguration.get( 71 | 'psalmScriptPath', 72 | join('vendor', 'vimeo', 'psalm', 'psalm-language-server') 73 | ); 74 | 75 | this.config.psalmScriptArgs = workspaceConfiguration.get( 76 | 'psalmScriptArgs', 77 | [] 78 | ); 79 | 80 | this.config.disableAutoComplete = workspaceConfiguration.get( 81 | 'disableAutoComplete', 82 | false 83 | ); 84 | 85 | this.config.maxRestartCount = workspaceConfiguration.get( 86 | 'maxRestartCount', 87 | 5 88 | ); 89 | 90 | this.config.unusedVariableDetection = workspaceConfiguration.get( 91 | 'unusedVariableDetection', 92 | false 93 | ); 94 | 95 | this.config.enableVerbose = workspaceConfiguration.get( 96 | 'enableVerbose', 97 | false 98 | ); 99 | 100 | this.config.connectToServerWithTcp = workspaceConfiguration.get( 101 | 'connectToServerWithTcp', 102 | false 103 | ); 104 | 105 | this.config.enableUseIniDefaults = workspaceConfiguration.get( 106 | 'enableUseIniDefaults', 107 | false 108 | ); 109 | 110 | this.config.logLevel = workspaceConfiguration.get('logLevel', 'INFO'); 111 | 112 | this.config.analyzedFileExtensions = workspaceConfiguration.get( 113 | 'analyzedFileExtensions', 114 | [{ scheme: 'file', language: 'php' }] 115 | ); 116 | 117 | this.config.configPaths = workspaceConfiguration.get('configPaths', [ 118 | 'psalm.xml', 119 | 'psalm.xml.dist', 120 | ]); 121 | 122 | this.config.hideStatusMessageWhenRunning = workspaceConfiguration.get( 123 | 'hideStatusMessageWhenRunning', 124 | false 125 | ); 126 | } 127 | 128 | public async validate(): Promise { 129 | // Check if the psalmServerScriptPath setting was provided. 130 | if (!this.config.psalmServerScriptPath) { 131 | await showOpenSettingsPrompt( 132 | 'The setting psalm.psalmScriptPath must be provided (e.g. vendor/bin/psalm-language-server)' 133 | ); 134 | return false; 135 | } 136 | return true; 137 | } 138 | 139 | public get(key: S): Config[S] { 140 | if (!(key in this.config)) { 141 | throw new Error(`Key ${key} not found in configuration`); 142 | } 143 | return this.config[key]; 144 | } 145 | 146 | public getAll(): Config { 147 | return this.config; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/LanguageServer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LanguageClient, 3 | StreamInfo, 4 | ErrorHandler, 5 | RevealOutputChannelOn, 6 | } from 'vscode-languageclient/node'; 7 | import { StatusBar, LanguageServerStatus } from './StatusBar'; 8 | import { spawn, ChildProcess } from 'child_process'; 9 | import { workspace, Uri, Disposable } from 'vscode'; 10 | import { format, URL } from 'url'; 11 | import { join, isAbsolute } from 'path'; 12 | import { execFile } from 'promisify-child-process'; 13 | import { ConfigurationService } from './ConfigurationService'; 14 | import LanguageServerErrorHandler from './LanguageServerErrorHandler'; 15 | import { statSync, constants } from 'fs'; 16 | import { access } from 'fs/promises'; 17 | import * as semver from 'semver'; 18 | import { LoggingService } from './LoggingService'; 19 | import { Writable } from 'stream'; 20 | import { createServer } from 'net'; 21 | import { showOpenSettingsPrompt, showErrorMessage } from './utils'; 22 | 23 | export class LanguageServer { 24 | private languageClient: LanguageClient; 25 | private workspacePath: string; 26 | private statusBar: StatusBar; 27 | private configurationService: ConfigurationService; 28 | private psalmConfigPath: string; 29 | private debug: boolean; 30 | private loggingService: LoggingService; 31 | private ready = false; 32 | private initalizing = false; 33 | private disposable: Disposable; 34 | private serverProcess: ChildProcess | null = null; 35 | 36 | constructor( 37 | workspacePath: string, 38 | psalmConfigPath: string, 39 | statusBar: StatusBar, 40 | configurationService: ConfigurationService, 41 | loggingService: LoggingService 42 | ) { 43 | this.workspacePath = workspacePath; 44 | this.statusBar = statusBar; 45 | this.configurationService = configurationService; 46 | this.psalmConfigPath = psalmConfigPath; 47 | this.loggingService = loggingService; 48 | 49 | this.languageClient = new LanguageClient( 50 | 'psalm', 51 | 'Psalm Language Server', 52 | this.serverOptions.bind(this), 53 | { 54 | outputChannel: this.loggingService, 55 | traceOutputChannel: this.loggingService, 56 | revealOutputChannelOn: RevealOutputChannelOn.Never, 57 | // Register the server for php (and maybe HTML) documents 58 | documentSelector: this.configurationService.get( 59 | 'analyzedFileExtensions' 60 | ), 61 | uriConverters: { 62 | // VS Code by default %-encodes even the colon after the drive letter 63 | // NodeJS handles it much better 64 | code2Protocol: (uri: Uri): string => 65 | format(new URL(uri.toString(true))), 66 | protocol2Code: (str: string): Uri => Uri.parse(str), 67 | }, 68 | synchronize: { 69 | // Synchronize the setting section 'psalm' to the server 70 | configurationSection: 'psalm', 71 | fileEvents: [ 72 | // this is for when files get changed outside of vscode 73 | workspace.createFileSystemWatcher('**/*.php'), 74 | workspace.createFileSystemWatcher('**/composer.lock'), 75 | ], 76 | }, 77 | progressOnInitialization: true, 78 | errorHandler: this.createDefaultErrorHandler( 79 | this.configurationService.get('maxRestartCount') - 1 80 | ), 81 | }, 82 | this.debug 83 | ); 84 | 85 | this.languageClient.onTelemetry(this.onTelemetry.bind(this)); 86 | } 87 | 88 | /** 89 | * This will NOT restart the server. 90 | * @param workspacePath 91 | */ 92 | public setWorkspacePath(workspacePath: string): void { 93 | this.workspacePath = workspacePath; 94 | } 95 | 96 | /** 97 | * This will NOT restart the server. 98 | * @param psalmConfigPath 99 | */ 100 | public setPsalmConfigPath(psalmConfigPath: string): void { 101 | this.psalmConfigPath = psalmConfigPath; 102 | } 103 | 104 | public createDefaultErrorHandler(maxRestartCount?: number): ErrorHandler { 105 | if (maxRestartCount !== undefined && maxRestartCount < 0) { 106 | throw new Error(`Invalid maxRestartCount: ${maxRestartCount}`); 107 | } 108 | return new LanguageServerErrorHandler( 109 | 'Psalm Language Server', 110 | maxRestartCount ?? 4 111 | ); 112 | } 113 | 114 | public getServerProcess(): ChildProcess | null { 115 | return this.serverProcess; 116 | } 117 | 118 | public isReady(): boolean { 119 | return this.ready; 120 | } 121 | 122 | public isInitalizing(): boolean { 123 | return this.initalizing; 124 | } 125 | 126 | public getDisposable(): Disposable { 127 | return this.disposable; 128 | } 129 | 130 | public getClient(): LanguageClient { 131 | return this.languageClient; 132 | } 133 | 134 | public async stop() { 135 | if (this.initalizing) { 136 | this.loggingService.logWarning( 137 | 'Server is in the process of intializing' 138 | ); 139 | return; 140 | } 141 | this.loggingService.logInfo('Stopping language server'); 142 | await this.languageClient.stop(); 143 | } 144 | 145 | public async start() { 146 | // Check if psalm is installed and supports the language server protocol. 147 | const isValidPsalmVersion: boolean = 148 | await this.checkPsalmHasLanguageServer(); 149 | if (!isValidPsalmVersion) { 150 | showOpenSettingsPrompt('Psalm is not installed'); 151 | return; 152 | } 153 | 154 | this.initalizing = true; 155 | this.statusBar.update(LanguageServerStatus.Initializing, 'starting'); 156 | this.loggingService.logInfo('Starting language server'); 157 | await this.languageClient.start(); 158 | // this.context.subscriptions.push(this.disposable); 159 | this.initalizing = false; 160 | this.ready = true; 161 | this.loggingService.logInfo('The Language Server is ready'); 162 | } 163 | 164 | public async restart() { 165 | this.loggingService.logInfo('Restarting language server'); 166 | await this.stop(); 167 | await this.start(); 168 | } 169 | 170 | public getLanguageClient(): LanguageClient { 171 | return this.languageClient; 172 | } 173 | 174 | /** 175 | * Get the PHP version 176 | * @return Promise A promise that resolves to the php version (Or null) 177 | */ 178 | public async getPHPVersion(): Promise { 179 | const out = await this.executePhp(['--version']); 180 | return out; 181 | } 182 | 183 | /** 184 | * Get the Psalm Language Server version 185 | * @return Promise A promise that resolves to the language server version (Or null) 186 | */ 187 | public async getPsalmLanguageServerVersion(): Promise { 188 | const psalmScriptPath = await this.resolvePsalmScriptPath(); 189 | 190 | try { 191 | await access(psalmScriptPath, constants.F_OK); 192 | } catch { 193 | const msg = `${psalmScriptPath} does not exist. Please set a valid path to psalm.psalmScriptPath`; 194 | await showErrorMessage(`Psalm can not start: ${msg}`); 195 | throw new Error(msg); 196 | } 197 | 198 | const psalmVersionOverride = 199 | this.configurationService.get('psalmVersion'); 200 | 201 | if ( 202 | typeof psalmVersionOverride !== 'undefined' && 203 | psalmVersionOverride !== null 204 | ) { 205 | this.loggingService.logWarning( 206 | `Psalm Version was overridden to "${psalmVersionOverride}".` + 207 | ' If this is not intentional please clear the Psalm Version Setting' 208 | ); 209 | return psalmVersionOverride; 210 | } 211 | 212 | try { 213 | const args: string[] = ['-f', psalmScriptPath, '--', '--version']; 214 | const out = await this.executePhp(args); 215 | // Psalm 4.8.1@f73f2299dbc59a3e6c4d66cff4605176e728ee69 216 | const ret = out.match(/^Psalm\s*((?:[0-9]+\.?)+)@([0-9a-f]{40})/); 217 | if (ret === null || ret.length !== 3) { 218 | this.loggingService.logWarning( 219 | `Psalm Version could not be parsed as a Semantic Version. Got "${out}". Assuming unknown` 220 | ); 221 | return null; 222 | } 223 | const [, version] = ret; 224 | this.loggingService.logInfo( 225 | `Psalm Version was detected as ${version}` 226 | ); 227 | return version; 228 | } catch (err) { 229 | this.loggingService.logWarning( 230 | `Psalm Version could not be detected. Got "${err.message}". Assuming unknown` 231 | ); 232 | return null; 233 | } 234 | } 235 | 236 | private onTelemetry(params: any) { 237 | if ( 238 | typeof params === 'object' && 239 | 'message' in params && 240 | typeof params.message === 'string' 241 | ) { 242 | // each time we get a new telemetry, we are going to check the config, and update as needed 243 | const hideStatusMessageWhenRunning = this.configurationService.get( 244 | 'hideStatusMessageWhenRunning' 245 | ); 246 | 247 | let status: string = params.message; 248 | 249 | if (params.message.indexOf(':') >= 0) { 250 | status = params.message.split(':')[0]; 251 | } 252 | 253 | switch (status) { 254 | case 'initializing': 255 | this.statusBar.update( 256 | LanguageServerStatus.Initializing, 257 | params.message 258 | ); 259 | break; 260 | case 'initialized': 261 | this.statusBar.update( 262 | LanguageServerStatus.Initialized, 263 | params.message 264 | ); 265 | break; 266 | case 'running': 267 | this.statusBar.update( 268 | LanguageServerStatus.Running, 269 | params.message 270 | ); 271 | break; 272 | case 'analyzing': 273 | this.statusBar.update( 274 | LanguageServerStatus.Analyzing, 275 | params.message 276 | ); 277 | break; 278 | case 'closing': 279 | this.statusBar.update( 280 | LanguageServerStatus.Closing, 281 | params.message 282 | ); 283 | break; 284 | case 'closed': 285 | this.statusBar.update( 286 | LanguageServerStatus.Closed, 287 | params.message 288 | ); 289 | break; 290 | } 291 | 292 | if (hideStatusMessageWhenRunning && status === 'running') { 293 | this.statusBar.hide(); 294 | } else { 295 | this.statusBar.show(); 296 | } 297 | } 298 | } 299 | 300 | private serverOptions(): Promise { 301 | return new Promise((resolve, reject) => { 302 | const connectToServerWithTcp = this.configurationService.get( 303 | 'connectToServerWithTcp' 304 | ); 305 | 306 | // Use a TCP socket on Windows because of problems with blocking STDIO 307 | // stdio locks up for large responses 308 | // (based on https://github.com/felixfbecker/vscode-php-intellisense/commit/ddddf2a178e4e9bf3d52efb07cd05820ce109f43) 309 | if (connectToServerWithTcp || process.platform === 'win32') { 310 | const server = createServer((socket) => { 311 | // 'connection' listener 312 | this.loggingService.logDebug('PHP process connected'); 313 | socket.on('end', () => { 314 | this.loggingService.logDebug( 315 | 'PHP process disconnected' 316 | ); 317 | }); 318 | 319 | if (this.loggingService.getOutputLevel() === 'TRACE') { 320 | socket.on('data', (chunk: Buffer) => { 321 | this.loggingService.logDebug( 322 | `SERVER ==> ${chunk}\n` 323 | ); 324 | }); 325 | } 326 | 327 | const writeable = new Writable(); 328 | 329 | // @ts-ignore 330 | writeable.write = (chunk, encoding, callback) => { 331 | if (this.loggingService.getOutputLevel() === 'TRACE') { 332 | this.loggingService.logDebug( 333 | chunk.toString 334 | ? `SERVER <== ${chunk.toString()}\n` 335 | : chunk 336 | ); 337 | } 338 | return socket.write(chunk, encoding, callback); 339 | }; 340 | 341 | server.close(); 342 | resolve({ reader: socket, writer: writeable }); 343 | }); 344 | server.listen(0, '127.0.0.1', () => { 345 | // Start the language server 346 | // make the language server connect to the client listening on (e.g. 127.0.0.1:) 347 | this.spawnServer([ 348 | // @ts-ignore 349 | '--tcp=127.0.0.1:' + server.address().port, 350 | ]); 351 | }); 352 | } else { 353 | // Use STDIO on Linux / Mac if the user set 354 | // the override `"psalm.connectToServerWithTcp": false` in their config. 355 | resolve(this.spawnServer()); 356 | } 357 | }); 358 | } 359 | 360 | /** 361 | * Spawn the Language Server as a child process 362 | * @param args Extra arguments to pass to the server 363 | * @return Promise A promise that resolves to the spawned process 364 | */ 365 | private async spawnServer(args: string[] = []): Promise { 366 | const languageServerVersion: string | null = 367 | await this.getPsalmLanguageServerVersion(); 368 | 369 | const extraServerArgs = 370 | this.configurationService.get('psalmScriptArgs'); 371 | 372 | if (extraServerArgs) { 373 | if (Array.isArray(extraServerArgs)) { 374 | args.unshift(...extraServerArgs); 375 | } 376 | } 377 | 378 | const unusedVariableDetection = this.configurationService.get( 379 | 'unusedVariableDetection' 380 | ); 381 | 382 | if (unusedVariableDetection) { 383 | args.unshift('--find-dead-code'); 384 | } 385 | 386 | const enableVerbose = this.configurationService.get('enableVerbose'); 387 | 388 | if (enableVerbose) { 389 | args.unshift('--verbose'); 390 | } 391 | 392 | const disableAutoComplete = this.configurationService.get( 393 | 'disableAutoComplete' 394 | ); 395 | 396 | if (disableAutoComplete) { 397 | args.unshift('--enable-autocomplete=false'); 398 | } 399 | 400 | // Are we running psalm or psalm-language-server 401 | // if we are runing psalm them we need to forward to psalm-language-server 402 | const psalmHasLanguageServerOption: boolean = 403 | await this.checkPsalmLanguageServerHasOption( 404 | [], 405 | '--language-server' 406 | ); 407 | 408 | const psalmScriptArgs: string[] = psalmHasLanguageServerOption 409 | ? ['--language-server'] 410 | : []; 411 | 412 | if ( 413 | languageServerVersion === null || 414 | semver.lt(languageServerVersion, '4.9.0') 415 | ) { 416 | if ( 417 | await this.checkPsalmLanguageServerHasOption( 418 | psalmScriptArgs, 419 | '--use-extended-diagnostic-codes' 420 | ) 421 | ) { 422 | psalmScriptArgs.unshift('--use-extended-diagnostic-codes'); 423 | } 424 | 425 | if ( 426 | enableVerbose && 427 | (await this.checkPsalmLanguageServerHasOption( 428 | psalmScriptArgs, 429 | '--verbose' 430 | )) 431 | ) { 432 | psalmScriptArgs.unshift('--verbose'); 433 | } 434 | } else if (semver.gte(languageServerVersion, '4.9.0')) { 435 | this.loggingService.logDebug( 436 | `Psalm Language Server Version: ${languageServerVersion}` 437 | ); 438 | psalmScriptArgs.unshift('--use-extended-diagnostic-codes'); 439 | if (enableVerbose) { 440 | psalmScriptArgs.unshift('--verbose'); 441 | } 442 | 443 | const enableUseIniDefaults = this.configurationService.get( 444 | 'enableUseIniDefaults' 445 | ); 446 | 447 | if (enableUseIniDefaults) { 448 | psalmScriptArgs.unshift('--use-ini-defaults'); 449 | } 450 | } 451 | 452 | args.unshift('-r', this.workspacePath); 453 | 454 | args.unshift('-c', this.psalmConfigPath); 455 | 456 | args.unshift(...psalmScriptArgs); 457 | 458 | // end of the psalm language server arguments, so we use the php cli argument separator 459 | args.unshift('--'); 460 | 461 | // The server is implemented in PHP 462 | // this goes before the cli argument separator 463 | const psalmScriptPath = await this.resolvePsalmScriptPath(); 464 | args.unshift('-f', psalmScriptPath); 465 | 466 | const { file, args: fileArgs } = await this.getPhpArgs(args); 467 | 468 | const childProcess = spawn(file, fileArgs, { 469 | cwd: this.workspacePath, 470 | }); 471 | this.serverProcess = childProcess; 472 | childProcess.stderr.on('data', (chunk: Buffer) => { 473 | this.loggingService.logError(chunk + ''); 474 | }); 475 | if (this.loggingService.getOutputLevel() === 'TRACE') { 476 | const orig = childProcess.stdin; 477 | 478 | childProcess.stdin = new Writable(); 479 | // @ts-ignore 480 | childProcess.stdin.write = (chunk, encoding, callback) => { 481 | this.loggingService.logDebug( 482 | chunk.toString ? `SERVER <== ${chunk.toString()}\n` : chunk 483 | ); 484 | return orig.write(chunk, encoding, callback); 485 | }; 486 | 487 | childProcess.stdout.on('data', (chunk: Buffer) => { 488 | this.loggingService.logDebug(`SERVER ==> ${chunk}\n`); 489 | }); 490 | } 491 | 492 | childProcess.on('exit', (code, signal) => { 493 | this.statusBar.update( 494 | LanguageServerStatus.Exited, 495 | 'Exited (Should Restart)' 496 | ); 497 | }); 498 | return childProcess; 499 | } 500 | 501 | /** 502 | * Check if the Psalm Language Server has an option 503 | * @param psalmScriptArgs The psalm script arguments 504 | * @param option The option to check for 505 | * @return Promise A promise that resolves to true if the option is found 506 | */ 507 | private async checkPsalmLanguageServerHasOption( 508 | psalmScriptArgs: string[], 509 | option: string 510 | ): Promise { 511 | const psalmScriptPath = await this.resolvePsalmScriptPath(); 512 | 513 | try { 514 | const args: string[] = [ 515 | '-f', 516 | psalmScriptPath, 517 | '--', 518 | '--help', 519 | ...psalmScriptArgs, 520 | ]; 521 | const out = await this.executePhp(args); 522 | 523 | const escaped = option.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); 524 | 525 | return new RegExp( 526 | '(\\b|\\s)' + escaped + '(?![-_])(\\b|\\s)', 527 | 'm' 528 | ).test(out); 529 | } catch (err) { 530 | return false; 531 | } 532 | } 533 | 534 | /** 535 | * Executes PHP with Arguments 536 | * @param args The arguments to pass to PHP 537 | * @return Promise A promise that resolves to the stdout of PHP 538 | */ 539 | private async executePhp(args: string[]): Promise { 540 | let stdout: string | Buffer | null | undefined; 541 | 542 | const { file, args: fileArgs } = await this.getPhpArgs(args); 543 | 544 | // eslint-disable-next-line prefer-const 545 | ({ stdout } = await execFile(file, fileArgs, { 546 | cwd: this.workspacePath, 547 | })); 548 | return String(stdout); 549 | } 550 | 551 | /** 552 | * Get the PHP Executable Location and Arguments to pass to PHP 553 | * 554 | * @param args The arguments to pass to PHP 555 | */ 556 | private async getPhpArgs( 557 | args: string[] 558 | ): Promise<{ file: string; args: string[] }> { 559 | const phpExecutablePath = 560 | this.configurationService.get('phpExecutablePath'); 561 | 562 | if (!phpExecutablePath || !phpExecutablePath.length) { 563 | const msg = 564 | 'Unable to find any php executable please set one in psalm.phpExecutablePath'; 565 | await showOpenSettingsPrompt(`Psalm can not start: ${msg}`); 566 | throw new Error(msg); 567 | } 568 | 569 | try { 570 | await access(phpExecutablePath, constants.X_OK); 571 | } catch { 572 | const msg = `${phpExecutablePath} is not executable`; 573 | await showErrorMessage(`Psalm can not start: ${msg}`); 574 | throw new Error(msg); 575 | } 576 | 577 | const phpExecutableArgs = 578 | this.configurationService.get('phpExecutableArgs'); 579 | 580 | if (phpExecutableArgs) { 581 | if ( 582 | Array.isArray(phpExecutableArgs) && 583 | phpExecutableArgs.length > 0 584 | ) { 585 | args.unshift(...phpExecutableArgs); 586 | } 587 | } 588 | 589 | return { file: phpExecutablePath, args }; 590 | } 591 | 592 | /** 593 | * Returns true if psalm.psalmScriptPath supports the language server protocol. 594 | * @return Promise A promise that resolves to true if the language server protocol is supported 595 | */ 596 | private async checkPsalmHasLanguageServer(): Promise { 597 | const psalmScriptPath = await this.resolvePsalmScriptPath(); 598 | 599 | const exists: boolean = this.isFile(psalmScriptPath); 600 | 601 | if (!exists) { 602 | this.loggingService.logError( 603 | `The setting psalm.psalmScriptPath refers to a path that does not exist. path: ${psalmScriptPath}` 604 | ); 605 | return false; 606 | } 607 | 608 | return true; 609 | } 610 | 611 | /** 612 | * Returns true if the file exists. 613 | */ 614 | private isFile(filePath: string): boolean { 615 | try { 616 | const stat = statSync(filePath); 617 | return stat.isFile(); 618 | } catch (e) { 619 | return false; 620 | } 621 | } 622 | 623 | /** 624 | * Resolve Pslam Script Path if absolute or relative 625 | */ 626 | private async resolvePsalmScriptPath(): Promise { 627 | const psalmScriptPath = 628 | this.configurationService.get('psalmScriptPath'); 629 | 630 | if (!psalmScriptPath) { 631 | await showErrorMessage( 632 | 'Unable to find Psalm Language Server. Please set psalm.psalmScriptPath' 633 | ); 634 | throw new Error('psalmScriptPath is not set'); 635 | } 636 | 637 | if (isAbsolute(psalmScriptPath)) { 638 | return psalmScriptPath; 639 | } 640 | return join(this.workspacePath, psalmScriptPath); 641 | } 642 | } 643 | -------------------------------------------------------------------------------- /src/LanguageServerErrorHandler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Message, 3 | ErrorHandler, 4 | ErrorAction, 5 | ErrorHandlerResult, 6 | CloseAction, 7 | CloseHandlerResult, 8 | } from 'vscode-languageclient/node'; 9 | import { showReportIssueErrorMessage } from './utils'; 10 | 11 | export default class LanguageServerErrorHandler implements ErrorHandler { 12 | private readonly restarts: number[]; 13 | 14 | constructor( 15 | private name: string, 16 | private maxRestartCount: number 17 | ) { 18 | this.restarts = []; 19 | } 20 | 21 | public error( 22 | error: Error, 23 | message: Message | undefined, 24 | count: number | undefined 25 | ): ErrorHandlerResult { 26 | if (count && count <= 3) { 27 | return { 28 | action: ErrorAction.Continue, 29 | }; 30 | } 31 | return { 32 | action: ErrorAction.Shutdown, 33 | }; 34 | } 35 | 36 | public closed(): CloseHandlerResult { 37 | this.restarts.push(Date.now()); 38 | if (this.restarts.length <= this.maxRestartCount) { 39 | return { action: CloseAction.Restart }; 40 | } else { 41 | const diff = 42 | this.restarts[this.restarts.length - 1] - this.restarts[0]; 43 | if (diff <= 3 * 60 * 1000) { 44 | const message = `The ${this.name} server crashed ${ 45 | this.maxRestartCount + 1 46 | } times in the last 3 minutes. The server will not be restarted.`; 47 | showReportIssueErrorMessage(message); 48 | return { action: CloseAction.DoNotRestart, message: message }; 49 | } else { 50 | this.restarts.shift(); 51 | return { action: CloseAction.Restart }; 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/LoggingService.ts: -------------------------------------------------------------------------------- 1 | import { window, OutputChannel } from 'vscode'; 2 | 3 | export type LogLevel = 'TRACE' | 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'NONE'; 4 | export class LoggingService implements OutputChannel { 5 | readonly name: string = 'Psalm Language Server'; 6 | 7 | private outputChannel = window.createOutputChannel('Psalm Language Server'); 8 | 9 | private logLevel: LogLevel = 'DEBUG'; 10 | 11 | private content: string[] = []; 12 | 13 | private contentLimit = 1000; 14 | 15 | /** 16 | * Replaces all output from the channel with the given value. 17 | * 18 | * @param value A string, falsy values will not be printed. 19 | */ 20 | replace(value: string): void { 21 | this.content = [value]; 22 | this.outputChannel.replace(value); 23 | } 24 | 25 | /** 26 | * Append the given value to the channel. 27 | * 28 | * @param value A string, falsy values will not be printed. 29 | */ 30 | append(value: string): void { 31 | this.content.push(value); 32 | this.content = this.content.slice(-this.contentLimit); 33 | this.outputChannel.append(value); 34 | } 35 | 36 | /** 37 | * Append the given value and a line feed character 38 | * to the channel. 39 | * 40 | * @param value A string, falsy values will be printed. 41 | */ 42 | appendLine(value: string): void { 43 | this.content.push(value); 44 | this.content = this.content.slice(-this.contentLimit); 45 | this.outputChannel.appendLine(value); 46 | } 47 | 48 | /** 49 | * Removes all output from the channel. 50 | */ 51 | clear(): void { 52 | this.content = []; 53 | this.outputChannel.clear(); 54 | } 55 | 56 | /** 57 | * Reveal this channel in the UI. 58 | * 59 | * @param preserveFocus When `true` the channel will not take focus. 60 | */ 61 | show(): void { 62 | this.outputChannel.show(...arguments); 63 | } 64 | 65 | /** 66 | * Hide this channel from the UI. 67 | */ 68 | hide(): void { 69 | this.outputChannel.hide(); 70 | } 71 | 72 | /** 73 | * Dispose and free associated resources. 74 | */ 75 | dispose(): void { 76 | this.outputChannel.dispose(); 77 | } 78 | 79 | public setOutputLevel(logLevel: LogLevel) { 80 | this.logLevel = logLevel; 81 | } 82 | 83 | public getOutputLevel(): LogLevel { 84 | return this.logLevel; 85 | } 86 | 87 | public logTrace(message: string, data?: unknown): void { 88 | if ( 89 | this.logLevel === 'NONE' || 90 | this.logLevel === 'INFO' || 91 | this.logLevel === 'WARN' || 92 | this.logLevel === 'ERROR' || 93 | this.logLevel === 'DEBUG' 94 | ) { 95 | return; 96 | } 97 | this.logMessage(message, 'TRACE'); 98 | if (data) { 99 | this.logObject(data); 100 | } 101 | } 102 | 103 | /** 104 | * Append messages to the output channel and format it with a title 105 | * 106 | * @param message The message to append to the output channel 107 | */ 108 | public logDebug(message: string, data?: unknown): void { 109 | if ( 110 | this.logLevel === 'NONE' || 111 | this.logLevel === 'INFO' || 112 | this.logLevel === 'WARN' || 113 | this.logLevel === 'ERROR' 114 | ) { 115 | return; 116 | } 117 | this.logMessage(message, 'DEBUG'); 118 | if (data) { 119 | this.logObject(data); 120 | } 121 | } 122 | 123 | /** 124 | * Append messages to the output channel and format it with a title 125 | * 126 | * @param message The message to append to the output channel 127 | */ 128 | public logInfo(message: string, data?: unknown): void { 129 | if ( 130 | this.logLevel === 'NONE' || 131 | this.logLevel === 'WARN' || 132 | this.logLevel === 'ERROR' 133 | ) { 134 | return; 135 | } 136 | this.logMessage(message, 'INFO'); 137 | if (data) { 138 | this.logObject(data); 139 | } 140 | } 141 | 142 | /** 143 | * Append messages to the output channel and format it with a title 144 | * 145 | * @param message The message to append to the output channel 146 | */ 147 | public logWarning(message: string, data?: unknown): void { 148 | if (this.logLevel === 'NONE' || this.logLevel === 'ERROR') { 149 | return; 150 | } 151 | this.logMessage(message, 'WARN'); 152 | if (data) { 153 | this.logObject(data); 154 | } 155 | } 156 | 157 | public logError(message: string, error?: Error | string) { 158 | if (this.logLevel === 'NONE') { 159 | return; 160 | } 161 | this.logMessage(message, 'ERROR'); 162 | if (typeof error === 'string') { 163 | // Errors as a string usually only happen with 164 | // plugins that don't return the expected error. 165 | this.appendLine(error); 166 | } else if (error?.message || error?.stack) { 167 | if (error?.message) { 168 | this.logMessage(error.message, 'ERROR'); 169 | } 170 | if (error?.stack) { 171 | this.appendLine(error.stack); 172 | } 173 | } else if (error) { 174 | this.logObject(error); 175 | } 176 | } 177 | 178 | public getContent(): string[] { 179 | return this.content; 180 | } 181 | 182 | private logObject(data: unknown): void { 183 | this.appendLine(JSON.stringify(data, null, 2)); 184 | } 185 | 186 | /** 187 | * Append messages to the output channel and format it with a title 188 | * 189 | * @param message The message to append to the output channel 190 | */ 191 | private logMessage(message: string, logLevel: LogLevel): void { 192 | const title = new Date().toLocaleTimeString(); 193 | this.appendLine(`[${logLevel} - ${title}] ${message}`); 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/StatusBar.ts: -------------------------------------------------------------------------------- 1 | import { StatusBarAlignment, StatusBarItem, ThemeColor, window } from 'vscode'; 2 | 3 | export enum LanguageServerStatus { 4 | Initializing = 'sync~spin', 5 | Initialized = 'zap', 6 | Running = 'check', 7 | Analyzing = 'sync~spin', 8 | Closing = 'issues', 9 | Closed = 'error', 10 | Exited = 'error', 11 | } 12 | 13 | export class StatusBar { 14 | private statusBarItem: StatusBarItem; 15 | constructor() { 16 | // Setup the statusBarItem 17 | this.statusBarItem = window.createStatusBarItem( 18 | 'psalm.status', 19 | StatusBarAlignment.Left, // this has a low priority so it will end up being more towards the right. 20 | 1 21 | ); 22 | this.statusBarItem.name = 'Psalm'; 23 | this.statusBarItem.tooltip = 'Psalm Language Server'; 24 | this.statusBarItem.hide(); 25 | } 26 | 27 | /** 28 | * Update the statusBarItem message and show the statusBarItem 29 | * 30 | * @param icon The the icon to use 31 | */ 32 | public update(result: LanguageServerStatus, text: string): void { 33 | this.statusBarItem.text = `$(${result.toString()}) Psalm: ${text}`; 34 | if (result === LanguageServerStatus.Exited) { 35 | this.statusBarItem.backgroundColor = new ThemeColor( 36 | 'statusBarItem.errorBackground' 37 | ); 38 | } else { 39 | this.statusBarItem.backgroundColor = undefined; 40 | } 41 | this.show(); 42 | } 43 | 44 | public show() { 45 | this.statusBarItem.show(); 46 | } 47 | 48 | public hide() { 49 | this.statusBarItem.hide(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/commands.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { ExitNotification } from 'vscode-languageclient/node'; 3 | import * as semver from 'semver'; 4 | import { LanguageServer } from './LanguageServer'; 5 | import * as path from 'path'; 6 | import { EXTENSION_ROOT_DIR } from './constants'; 7 | import { formatFromTemplate } from './utils'; 8 | import { ConfigurationService } from './ConfigurationService'; 9 | import { LoggingService } from './LoggingService'; 10 | import { EOL } from 'os'; 11 | interface Command { 12 | id: string; 13 | execute(): void; 14 | } 15 | 16 | async function restartSever( 17 | client: LanguageServer, 18 | configurationService: ConfigurationService 19 | ) { 20 | const languageServerVersion = await client.getPsalmLanguageServerVersion(); 21 | if (languageServerVersion === null) { 22 | const reload = await vscode.window.showWarningMessage( 23 | 'This version of Psalm has a bug in that the only way' + 24 | 'to force the Language Server to re-analyze the workspace' + 25 | `is to forcefully crash it. VSCode limitations only allow us to do this ${configurationService.get( 26 | 'maxRestartCount' 27 | )} times per session. Consider upgrading to at least 4.9.0 of Psalm`, 28 | 'Ok', 29 | 'Cancel' 30 | ); 31 | if (reload === 'Ok') { 32 | client.getClient().sendNotification(ExitNotification.type); 33 | } 34 | } else if (semver.gte(languageServerVersion, '4.9.0')) { 35 | await client.stop(); 36 | client.start(); 37 | } 38 | } 39 | 40 | function analyzeWorkSpace( 41 | client: LanguageServer, 42 | configurationService: ConfigurationService 43 | ): Command { 44 | return { 45 | id: 'psalm.analyzeWorkSpace', 46 | async execute() { 47 | return await restartSever(client, configurationService); 48 | }, 49 | }; 50 | } 51 | 52 | function restartPsalmServer( 53 | client: LanguageServer, 54 | configurationService: ConfigurationService 55 | ): Command { 56 | return { 57 | id: 'psalm.restartPsalmServer', 58 | async execute() { 59 | return await restartSever(client, configurationService); 60 | }, 61 | }; 62 | } 63 | 64 | function reportIssue( 65 | client: LanguageServer, 66 | configurationService: ConfigurationService, 67 | loggingService: LoggingService 68 | ): Command { 69 | return { 70 | id: 'psalm.reportIssue', 71 | async execute() { 72 | const templatePath = path.join( 73 | EXTENSION_ROOT_DIR, 74 | 'resources', 75 | 'report_issue_template.md' 76 | ); 77 | 78 | const userSettings = Object.entries(configurationService.getAll()) 79 | .map(([key, value]) => `${key}: ${JSON.stringify(value)}`) 80 | .join(EOL); 81 | const psalmLogs = loggingService.getContent().join(EOL); 82 | 83 | let phpVersion = 'unknown'; 84 | try { 85 | phpVersion = (await client.getPHPVersion()) ?? 'unknown'; 86 | } catch (err) { 87 | phpVersion = err.message; 88 | } 89 | 90 | let psalmVersion: string | null = 'unknown'; 91 | try { 92 | psalmVersion = 93 | (await client.getPsalmLanguageServerVersion()) ?? 'unknown'; 94 | } catch (err) { 95 | psalmVersion = err.message; 96 | } 97 | 98 | await vscode.commands.executeCommand( 99 | 'workbench.action.openIssueReporter', 100 | { 101 | extensionId: 'getpsalm.psalm-vscode-plugin', 102 | issueBody: await formatFromTemplate( 103 | templatePath, 104 | phpVersion, // 0 105 | psalmVersion, // 1 106 | psalmLogs, // 2 107 | userSettings // 3 108 | ), 109 | } 110 | ); 111 | }, 112 | }; 113 | } 114 | 115 | function showOutput(loggingService: LoggingService): Command { 116 | return { 117 | id: 'psalm.showOutput', 118 | async execute() { 119 | loggingService.show(); 120 | }, 121 | }; 122 | } 123 | 124 | export function registerCommands( 125 | client: LanguageServer, 126 | configurationService: ConfigurationService, 127 | loggingService: LoggingService 128 | ): vscode.Disposable[] { 129 | const commands: Command[] = [ 130 | restartPsalmServer(client, configurationService), 131 | analyzeWorkSpace(client, configurationService), 132 | reportIssue(client, configurationService, loggingService), 133 | showOutput(loggingService), 134 | ]; 135 | 136 | const disposables = commands.map((command) => 137 | vscode.commands.registerCommand(command.id, command.execute) 138 | ); 139 | 140 | return disposables; 141 | } 142 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | 'use strict'; 5 | 6 | import * as path from 'path'; 7 | 8 | export const EXTENSION_ROOT_DIR = path.join(__dirname, '..'); 9 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { StatusBar } from './StatusBar'; 3 | import { LoggingService } from './LoggingService'; 4 | import { ConfigurationService } from './ConfigurationService'; 5 | import { LanguageServer } from './LanguageServer'; 6 | import { registerCommands } from './commands'; 7 | import { showWarningMessage } from './utils'; 8 | 9 | /** 10 | * Activate the extension. 11 | * 12 | * NOTE: This is only ever run once so it's safe to listen to events here 13 | */ 14 | export async function activate( 15 | context: vscode.ExtensionContext 16 | ): Promise { 17 | // @ts-ignore 18 | const loggingService = new LoggingService(); 19 | // @ts-ignore 20 | const configurationService = new ConfigurationService(); 21 | await configurationService.init(); 22 | 23 | // Set Logging Level 24 | loggingService.setOutputLevel(configurationService.get('logLevel')); 25 | 26 | // @ts-ignore 27 | const statusBar = new StatusBar(); 28 | 29 | const workspaceFolders = vscode.workspace.workspaceFolders; 30 | 31 | if (!workspaceFolders) { 32 | loggingService.logError( 33 | 'Psalm must be run in a workspace. Select a workspace and reload the window' 34 | ); 35 | return; 36 | } 37 | 38 | const getCurrentWorkspace = ( 39 | workspaceFolders1: readonly vscode.WorkspaceFolder[] 40 | ) => { 41 | const { uri } = vscode.window.activeTextEditor?.document ?? { 42 | uri: undefined, 43 | }; 44 | const activeWorkspace = uri 45 | ? vscode.workspace.getWorkspaceFolder(uri) 46 | : workspaceFolders1[0]; 47 | 48 | const workspacePath1 = activeWorkspace 49 | ? activeWorkspace.uri.fsPath 50 | : workspaceFolders1[0].uri.fsPath; 51 | 52 | return { workspacePath: workspacePath1 }; 53 | }; 54 | 55 | const getOptions = async () => { 56 | const configPaths1 = configurationService.get('configPaths') || []; 57 | 58 | const psalmXMLFiles = await vscode.workspace.findFiles( 59 | `{${configPaths1.join(',')}}` 60 | // `**/vendor/**/{${configPaths.join(',')}}` 61 | ); 62 | 63 | const psalmXMLPaths1 = psalmXMLFiles.map((uri) => { 64 | if (process.platform === 'win32') { 65 | return uri.path.replace(/\//g, '\\').replace(/^\\/g, ''); 66 | } 67 | return uri.path; 68 | }); 69 | 70 | const { workspacePath: workspacePath1 } = 71 | getCurrentWorkspace(workspaceFolders); 72 | 73 | const configXml1 = 74 | psalmXMLPaths1.find((path) => path.startsWith(workspacePath1)) ?? 75 | psalmXMLPaths1[0]; 76 | 77 | return { 78 | configPaths: configPaths1, 79 | psalmXMLFiles, 80 | psalmXMLPaths: psalmXMLPaths1, 81 | configXml: configXml1, 82 | workspacePath: workspacePath1, 83 | }; 84 | }; 85 | 86 | let { 87 | configPaths, 88 | // psalmXMLFiles, 89 | psalmXMLPaths, 90 | configXml, 91 | workspacePath, 92 | } = await getOptions(); 93 | 94 | if (!configPaths.length) { 95 | loggingService.logError( 96 | 'No Config Paths defined. Define some and reload the window' 97 | ); 98 | return; 99 | } 100 | 101 | if (!psalmXMLPaths.length) { 102 | // no psalm.xml found 103 | loggingService.logError( 104 | `No Config file found in: ${configPaths.join(',')}` 105 | ); 106 | return; 107 | } 108 | 109 | loggingService.logDebug( 110 | 'Found the following Psalm XML Configs:', 111 | psalmXMLPaths 112 | ); 113 | 114 | loggingService.logDebug(`Selecting first found config file: ${configXml}`); 115 | 116 | let configWatcher = vscode.workspace.createFileSystemWatcher(configXml); 117 | 118 | const languageServer = new LanguageServer( 119 | workspacePath, 120 | configXml, 121 | statusBar, 122 | configurationService, 123 | loggingService 124 | ); 125 | 126 | // restart the language server when changing workspaces 127 | const onWorkspacePathChange = async () => { 128 | // kill the previous watcher 129 | configWatcher.dispose(); 130 | configWatcher = vscode.workspace.createFileSystemWatcher(configXml); 131 | loggingService.logInfo(`Workspace changed: ${workspacePath}`); 132 | languageServer.setWorkspacePath(workspacePath); 133 | languageServer.setPsalmConfigPath(configXml); 134 | languageServer.restart(); 135 | }; 136 | 137 | const onConfigChange = () => { 138 | loggingService.logInfo(`Config file changed: ${configXml}`); 139 | languageServer.restart(); 140 | }; 141 | 142 | const onConfigDelete = () => { 143 | loggingService.logInfo(`Config file deleted: ${configXml}`); 144 | languageServer.stop(); 145 | }; 146 | 147 | // Restart the language server when the tracked config file changes 148 | configWatcher.onDidChange(onConfigChange); 149 | configWatcher.onDidCreate(onConfigChange); 150 | configWatcher.onDidDelete(onConfigDelete); 151 | 152 | context.subscriptions.push( 153 | ...registerCommands( 154 | languageServer, 155 | configurationService, 156 | loggingService 157 | ) 158 | ); 159 | 160 | // Start Lanuage Server 161 | await languageServer.start(); 162 | 163 | vscode.workspace.onDidChangeConfiguration(async (change) => { 164 | if ( 165 | !change.affectsConfiguration('psalm') || 166 | change.affectsConfiguration('psalm.hideStatusMessageWhenRunning') 167 | ) { 168 | return; 169 | } 170 | loggingService.logDebug('Configuration changed'); 171 | showWarningMessage( 172 | 'You will need to reload this window for the new configuration to take effect' 173 | ); 174 | 175 | await configurationService.init(); 176 | }); 177 | 178 | vscode.window.onDidChangeActiveTextEditor(async (e) => { 179 | if (!e) { 180 | return; 181 | } 182 | 183 | const options = await getOptions(); 184 | 185 | if (!options.workspacePath || workspacePath === options.workspacePath) { 186 | return; 187 | } 188 | 189 | configPaths = options.configPaths; 190 | configXml = options.configXml; 191 | // psalmXMLFiles = options.psalmXMLFiles; 192 | psalmXMLPaths = options.psalmXMLPaths; 193 | workspacePath = options.workspacePath; 194 | 195 | onWorkspacePathChange(); 196 | }); 197 | 198 | loggingService.logDebug('Finished Extension Activation'); 199 | } 200 | 201 | export async function deactivate() { 202 | // Extensions should now implement a deactivate function in 203 | // their extension main file and correctly return the stop 204 | // promise from the deactivate call. 205 | } 206 | -------------------------------------------------------------------------------- /src/processes.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psalm/psalm-vscode-plugin/c03b6f87445991389eb268cb49452b446a76d56a/src/processes.ts -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { window, commands } from 'vscode'; 2 | import * as fs from 'fs-extra'; 3 | 4 | export async function showOpenSettingsPrompt( 5 | errorMessage: string 6 | ): Promise { 7 | const selected = await window.showErrorMessage( 8 | errorMessage, 9 | 'Open settings' 10 | ); 11 | if (selected === 'Open settings') { 12 | await commands.executeCommand('workbench.action.openGlobalSettings'); 13 | } 14 | } 15 | 16 | export async function showReportIssueErrorMessage( 17 | errorMessage: string 18 | ): Promise { 19 | const selected = await window.showErrorMessage( 20 | errorMessage, 21 | 'Report Issue' 22 | ); 23 | if (selected === 'Report Issue') { 24 | await commands.executeCommand('psalm.reportIssue'); 25 | } 26 | } 27 | 28 | export async function showErrorMessage( 29 | errorMessage: string 30 | ): Promise { 31 | return window.showErrorMessage(errorMessage); 32 | } 33 | 34 | export async function showWarningMessage( 35 | errorMessage: string 36 | ): Promise { 37 | return window.showWarningMessage(errorMessage); 38 | } 39 | 40 | export async function formatFromTemplate(templatePath: string, ...args: any[]) { 41 | const template = await fs.readFile(templatePath, 'utf8'); 42 | 43 | return template.replace(/{(\d+)}/g, (match, number) => 44 | args[number] === undefined ? match : args[number] 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES2020", 5 | "outDir": "out", 6 | "lib": [ 7 | "ES2020" 8 | ], 9 | "noImplicitAny": true, 10 | "sourceMap": true, 11 | "rootDir": "src", 12 | "strictNullChecks": true, 13 | "noImplicitThis": true, 14 | "noImplicitReturns": true, 15 | "noUnusedLocals": true, 16 | "allowUnreachableCode": false, 17 | "allowUnusedLabels": false, 18 | "esModuleInterop": true 19 | } 20 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | 3 | 'use strict'; 4 | 5 | const path = require('path'); 6 | 7 | //@ts-check 8 | /** @typedef {import('webpack').Configuration} WebpackConfig **/ 9 | 10 | /** @type WebpackConfig */ 11 | const extensionConfig = { 12 | target: 'node', // VS Code extensions run in a Node.js-context 📖 -> https://webpack.js.org/configuration/node/ 13 | mode: 'none', // this leaves the source code as close as possible to the original (when packaging we set this to 'production') 14 | 15 | entry: './src/extension.ts', // the entry point of this extension, 📖 -> https://webpack.js.org/configuration/entry-context/ 16 | output: { 17 | // the bundle is stored in the 'dist' folder (check package.json), 📖 -> https://webpack.js.org/configuration/output/ 18 | path: path.resolve(__dirname, 'dist'), 19 | filename: 'extension.js', 20 | libraryTarget: 'commonjs2', 21 | }, 22 | externals: { 23 | vscode: 'commonjs vscode', // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/ 24 | // modules added here also need to be added in the .vscodeignore file 25 | }, 26 | resolve: { 27 | // support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader 28 | extensions: ['.ts', '.js'], 29 | }, 30 | module: { 31 | rules: [ 32 | { 33 | test: /\.ts$/, 34 | exclude: /node_modules/, 35 | use: [ 36 | { 37 | loader: 'ts-loader', 38 | }, 39 | ], 40 | }, 41 | ], 42 | }, 43 | devtool: 'nosources-source-map', 44 | infrastructureLogging: { 45 | level: 'log', // enables logging required for problem matchers 46 | }, 47 | }; 48 | module.exports = [extensionConfig]; 49 | --------------------------------------------------------------------------------