├── .eslintrc.json ├── .github └── workflows │ ├── build.yml │ └── close-stale-issues.yml ├── .gitignore ├── .prettierrc ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── bun.lockb ├── client ├── bun.lockb ├── package.json ├── src │ ├── extension.ts │ ├── lib │ │ ├── commands.ts │ │ ├── dev.ts │ │ ├── editorConfig.ts │ │ ├── errorUtil.ts │ │ ├── files.ts │ │ ├── installationConfig.ts │ │ ├── log.ts │ │ ├── multiStepInput.ts │ │ ├── notificationChannels.ts │ │ ├── requestChannels.ts │ │ └── setup.ts │ ├── notificationReceivers │ │ ├── configResolveLanguageStatus.ts │ │ ├── debug.ts │ │ ├── errorManager.ts │ │ ├── pro.ts │ │ ├── statusBar.ts │ │ └── zombieKiller.ts │ └── notificationSenders │ │ └── documentManager.ts └── yarn.lock ├── knip.ts ├── package.json ├── php ├── Diagnoser.php ├── TreeFetcher.php ├── composer.json ├── composer.lock ├── config.2.neon └── config.neon ├── scripts └── get-file-tree.ts ├── server ├── bun.lockb ├── package.json ├── src │ ├── lib │ │ ├── checkConfigManager.ts │ │ ├── commands.ts │ │ ├── configResolver.ts │ │ ├── debug.ts │ │ ├── documentManager.ts │ │ ├── editorConfig.ts │ │ ├── errorUtil.ts │ │ ├── log.ts │ │ ├── notificationChannels.ts │ │ ├── phpstan │ │ │ ├── check.ts │ │ │ ├── checkManager.ts │ │ │ ├── pro │ │ │ │ ├── pro.ts │ │ │ │ └── proErrorManager.ts │ │ │ └── processRunner.ts │ │ ├── process.ts │ │ ├── requestChannels.ts │ │ ├── result.ts │ │ ├── statusBar.ts │ │ ├── test.ts │ │ ├── types.ts │ │ └── watcher.ts │ ├── providers │ │ ├── hoverProvider.ts │ │ └── providerUtil.ts │ ├── server.ts │ └── start │ │ ├── getVersion.ts │ │ ├── startIntegratedChecker.ts │ │ └── startPro.ts └── yarn.lock ├── shared ├── commands │ └── defs.ts ├── config.ts ├── constants.ts ├── debug.ts ├── neon.ts ├── notificationChannels.ts ├── requestChannels.ts ├── statusBar.ts ├── types.ts ├── util.ts └── variables.ts ├── static └── images │ └── phpstan.png ├── test ├── demo.2 │ ├── .vscode │ │ └── settings.json │ ├── autoloader.php │ ├── composer.json │ ├── composer.lock │ ├── php │ │ ├── DemoClass copy.php │ │ └── DemoClass.php │ ├── phpstan-with-rule.neon │ └── phpstan.neon ├── demo │ ├── .vscode │ │ └── settings.json │ ├── autoloader.php │ ├── composer.json │ ├── composer.lock │ ├── php │ │ ├── DemoClass copy.php │ │ └── DemoClass.php │ ├── phpstan-with-rule.neon │ └── phpstan.neon ├── exclude-config-demo │ ├── .vscode │ │ └── settings.json │ ├── autoloader.php │ ├── composer.json │ ├── composer.lock │ ├── php │ │ ├── Light.php │ │ ├── heavy.neon │ │ ├── heavy │ │ │ └── Heavy.php │ │ └── light.neon │ └── phpstan-with-rule.neon ├── multi-config-demo │ ├── .vscode │ │ └── settings.json │ ├── autoloader.php │ ├── composer.json │ ├── composer.lock │ ├── phpstan.neon │ ├── src │ │ ├── php │ │ │ └── DemoClass.php │ │ └── phpstan.neon │ └── test │ │ ├── php │ │ └── DemoClass.php │ │ └── phpstan.neon └── multi-workspace-demo │ ├── multi-workspace.code-workspace │ ├── primary │ ├── composer.json │ ├── composer.lock │ ├── php │ │ └── DemoClass.php │ └── phpstan.neon │ └── secondary │ ├── php │ └── DemoClass.php │ └── phpstan.neon ├── tsconfig.json ├── types ├── neon-js.d.ts └── vscode.proposed.portsAttributes.d.ts └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@typescript-eslint", "unused-imports"], 3 | "extends": [ 4 | "./node_modules/gts/", 5 | "plugin:@typescript-eslint/recommended-requiring-type-checking" 6 | ], 7 | "parserOptions": { 8 | "tsconfigRootDir": "./", 9 | "project": ["./tsconfig.json"], 10 | "createDefaultProgram": true 11 | }, 12 | "rules": { 13 | "quotes": ["warn", "single", { "avoidEscape": true }], 14 | "prefer-arrow-callback": "error", 15 | "object-property-newline": "warn", 16 | "curly": "error", 17 | "eqeqeq": "warn", 18 | "no-async-promise-executor": "warn", 19 | "no-case-declarations": "warn", 20 | "no-prototype-builtins": "warn", 21 | "no-useless-escape": "warn", 22 | "prefer-const": "warn", 23 | "prefer-spread": "warn", 24 | "prettier/prettier": "warn", 25 | "node/no-extraneous-import": "off", 26 | "@typescript-eslint/adjacent-overload-signatures": "warn", 27 | "@typescript-eslint/await-thenable": "warn", 28 | "@typescript-eslint/ban-types": "warn", 29 | "@typescript-eslint/explicit-module-boundary-types": "warn", 30 | "@typescript-eslint/no-explicit-any": "warn", 31 | "@typescript-eslint/no-floating-promises": "warn", 32 | "@typescript-eslint/no-for-in-array": "warn", 33 | "@typescript-eslint/no-implied-eval": ["warn"], 34 | "@typescript-eslint/no-misused-promises": "warn", 35 | "@typescript-eslint/no-unused-expressions": "warn", 36 | "@typescript-eslint/no-unused-vars": "warn", 37 | "@typescript-eslint/no-unsafe-assignment": ["off"], 38 | "@typescript-eslint/no-unsafe-call": ["off"], 39 | "@typescript-eslint/no-unsafe-member-access": ["warn"], 40 | "@typescript-eslint/no-unsafe-return": ["warn"], 41 | "@typescript-eslint/no-this-alias": "warn", 42 | "@typescript-eslint/no-unnecessary-type-assertion": "warn", 43 | "@typescript-eslint/prefer-optional-chain": "warn", 44 | "@typescript-eslint/prefer-regexp-exec": "warn", 45 | "@typescript-eslint/require-await": "warn", 46 | "@typescript-eslint/restrict-plus-operands": "warn", 47 | "@typescript-eslint/restrict-template-expressions": "warn", 48 | "@typescript-eslint/unbound-method": "warn", 49 | "no-debugger": "warn", 50 | "@typescript-eslint/no-inferrable-types": "off", 51 | "node/no-unpublished-import": "off", 52 | "@typescript-eslint/consistent-type-imports": "warn", 53 | "@typescript-eslint/prefer-readonly": [ 54 | "error", 55 | { "onlyInlineLambdas": true } 56 | ], 57 | "@typescript-eslint/explicit-member-accessibility": [ 58 | "warn", 59 | { 60 | "accessibility": "explicit" 61 | } 62 | ], 63 | "@typescript-eslint/explicit-function-return-type": [ 64 | "warn", 65 | { 66 | "allowExpressions": true, 67 | "allowTypedFunctionExpressions": true, 68 | "allowHigherOrderFunctions": true, 69 | "allowConciseArrowFunctionExpressionsStartingWithVoid": true 70 | } 71 | ], 72 | "unused-imports/no-unused-imports": "warn" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: 'Format, lint and check for code errors' 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - '**.md' 7 | schedule: 8 | - cron: '0 0 1 * *' 9 | create: 10 | tags: 11 | - '*' 12 | 13 | jobs: 14 | lint-and-tsc: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v2.3.1 19 | 20 | - name: Node 21 | uses: actions/setup-node@v2 22 | with: 23 | node-version: 20 24 | 25 | - uses: oven-sh/setup-bun@v1 26 | 27 | - name: Install 28 | run: bun devBun 29 | 30 | - name: Format 31 | run: bun prettier --check client server 32 | 33 | - name: Lint 34 | run: bun lint 35 | 36 | - name: TypeScript 37 | run: bun compile 38 | 39 | - name: Check if package was just generated 40 | run: bun generate-package && git diff --exit-code package.json 41 | -------------------------------------------------------------------------------- /.github/workflows/close-stale-issues.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | on: 3 | schedule: 4 | - cron: '30 1 * * *' 5 | workflow_dispatch: 6 | 7 | jobs: 8 | stale: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/stale@v5 12 | with: 13 | stale-issue-message: 'Issues go stale after too much time without activity. If inactive for another 7 days this issue will be closed.' -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | node_modules 4 | .vscode-test/ 5 | *.vsix 6 | 7 | .pnp.* 8 | php/vendor 9 | test/demo/vendor 10 | test/demo/cache 11 | test/demo/reported.json 12 | cache/phpstan 13 | test/multi-config-demo/vendor 14 | test/multi-config-demo/cache 15 | test/multi-config-demo/reported.json 16 | test/multi-workspace-demo/primary/vendor 17 | test/multi-workspace-demo/primary/cache 18 | test/multi-workspace-demo/secondary/vendor 19 | test/multi-workspace-demo/secondary/cache 20 | test/exclude-config-demo/vendor 21 | test/exclude-config-demo/cache 22 | test/demo.2/vendor 23 | test/demo.2/cache 24 | test/demo.2/reported.json 25 | test/scratchpad 26 | test/cacheDir 27 | **/reported.json 28 | user_config.json 29 | test/cache/phpstan 30 | 31 | _config -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "useTabs": true, 4 | "tabWidth": 4, 5 | "singleQuote": true, 6 | "arrowParens": "always", 7 | "bracketSameLine": false, 8 | "printWidth": 80, 9 | "plugins": ["./node_modules/prettier-plugin-sort-imports/dist/index.3.js"], 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": ["dbaeumer.vscode-eslint"] 5 | } 6 | -------------------------------------------------------------------------------- /.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": "Launch Client", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "args": [ 13 | "--disable-extensions", 14 | "--extensionDevelopmentPath=${workspaceFolder}", 15 | "--enable-proposed-api", 16 | "SanderRonde.phpstan-vscode" 17 | ], 18 | "outFiles": ["${workspaceFolder}/out/**/*.js"], 19 | "preLaunchTask": "build-debug" 20 | }, 21 | { 22 | "type": "node", 23 | "request": "attach", 24 | "name": "Attach to Server", 25 | "port": 6009, 26 | "restart": true, 27 | "outFiles": ["${workspaceRoot}/out/**/*.js"] 28 | } 29 | ], 30 | "compounds": [ 31 | { 32 | "name": "Client + Server", 33 | "configurations": ["Launch Client", "Attach to Server"], 34 | "preLaunchTask": "build-debug:attach-server" 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false, // set this to true to hide the "out" folder with the compiled JS files 5 | "dist": false // set this to true to hide the "dist" folder with the compiled JS files 6 | }, 7 | "search.exclude": { 8 | "out": true, // set this to false to include "out" folder in search results 9 | "dist": true // set this to false to include "dist" folder in search results 10 | }, 11 | "npm.packageManager": "bun", 12 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 13 | "typescript.tsc.autoDetect": "off", 14 | "phpstan.whenToRun": "never" 15 | } 16 | -------------------------------------------------------------------------------- /.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": "build-debug", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | }, 18 | "label": "build-debug", 19 | "detail": "npm-watch build-debug" 20 | }, 21 | { 22 | "type": "npm", 23 | "script": "build-debug:attach-server", 24 | "problemMatcher": "$tsc-watch", 25 | "isBackground": true, 26 | "presentation": { 27 | "reveal": "never" 28 | }, 29 | "group": { 30 | "kind": "build", 31 | "isDefault": true 32 | }, 33 | "label": "build-debug:attach-server", 34 | "detail": "npm-watch build-debug:attach-server" 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | src/** 4 | scripts/** 5 | .gitignore 6 | vsc-extension-quickstart.md 7 | **/tsconfig.json 8 | **/.eslintrc.json 9 | **/*.map 10 | **/*.ts 11 | !src/images 12 | php/vendor 13 | test/demo/vendor 14 | test/demo/reported.json 15 | node_modules 16 | client 17 | server 18 | shared 19 | php/composer.* 20 | test 21 | .github 22 | _config/** 23 | bun.lockb -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Great that you want to contribute to this project! 2 | 3 | ## Opening an issue 4 | 5 | > [!IMPORTANT] 6 | > A simple test to perform before you open an issue is: if you can run PHPStan on the command line with the exact same settings and your issue persists, then it's not something this extension can fix and it should instead be reported to [the PHPStan project](https://github.com/phpstan/phpstan/issues). 7 | 8 | First it's good to know what this extension is and what it is not. 9 | 10 | This extension is a VSCode integration for PHPStan that can display the resulting errors, as well as it being able to queue a new check when any files in your project change. It is not affiliated with the PHPStan project itself. It is merely a wrapper around the PHPStan binary. 11 | 12 | Examples of things this extension **can** do: 13 | 14 | - Display errors in the editor (potential issue: errors are not displayed or are displayed on the wrong line) 15 | - Watch for changes in open files (potential issue: file is not checked when it has just been changed and should be checked) 16 | 17 | Examples of things this extension can **not** do: 18 | 19 | - Fix issues related to the running of PHPStan itself (potential issue: PHPStan is not running because there is no `vendor` directory) 20 | - Fix parallelization-related issues or issues related to it being too heavy to run (potential issue: PHPStan takes too long to run, PHPStan takes up too many system resources) 21 | - Fix wrongly reported errors (potential issue: "missing return type" is reported but the function does have a return type) 22 | 23 | ## Contributing code 24 | 25 | See [the development section](https://github.com/SanderRonde/phpstan-vscode#development) of the README. 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Sander Ronde (awsdfgvhbjn@gmail.com) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![VSCode Installs](https://img.shields.io/vscode-marketplace/i/sanderronde.phpstan-vscode.svg?label=VSCode%20Marketplace%20Installs) 2 | 3 | ## Features 4 | 5 | - Automatically runs PHPStan of your code and highlights errors as you type. 6 | - Performs project-wide analysis and displays all errors in the `Diagnostics` tab. 7 | - Shows the values of variables according to PHPStan at the point of hovering when using `phpstan.showTypeOnHover` setting. 8 | 9 | https://user-images.githubusercontent.com/5385012/188924277-c9392477-9bd6-40b1-9ed7-eb892da1fe0f.mp4 10 | 11 | ## Configuration 12 | 13 | ### Main Config 14 | 15 | - `phpstan.configFile` - path to the config file, either relative to `phpstan.rootDir` or absolute. Use a comma-separated list to resolve in the listed order. For example if `phpstan.neon,phpstan.neon.dist` is used, the extension will first try to use `phpstan.neon` if it exists and fall back to `phpstan.neon.dist`. 16 | - `phpstan.rootDir` - path to the root directory of your PHP project (defaults to `workspaceFolder`) 17 | - `phpstan.binPath` - path to the PHPStan binary (defaults to `${workspaceFolder}/vendor/bin/phpstan`) 18 | - `phpstan.binCommand` - command that runs the PHPStan binary. Use this if, for example, PHPStan is already in your global path. If this is specified, it is used instead of `phpstan.binPath`. For example `["lando", "phpstan"]` or `["docker", "exec", "-t", "phpstan"]`. Unset by default. 19 | - `phpstan.pro` - Enable PHPStan Pro support. Runs PHPStan Pro in the background and leaves watching to PHPStan while displaying any errors it catches in the editor. This requires a valid license. False by default. 20 | - `phpstan.singleFileMode` - Whether to scan only the file that is being saved, instead of the whole project. This is not recommended since it busts the cache. Only use this if your computer can't handle a full-project scan 21 | 22 | ### Tuning 23 | 24 | - `phpstan.options` - array of command line options to pass to PHPStan (defaults to `[]`) 25 | - `phpstan.memoryLimit` - memory limit to use when running PHPStan (defaults to `1G`) 26 | - `phpstan.projectTimeout` - timeout for checking the entire project after which the PHPStan process is killed in ms (defaults to 60000ms) 27 | - `phpstan.timeout` - timeout for checking single files after which the PHPStan process is killed in ms (defaults to 10000ms). Only used if the `phpstan.singleFileMode` setting is enabled. 28 | - `phpstan.suppressTimeoutMessage` - whether to disable the error message when the check times out (defaults to `false`) 29 | - `phpstan.paths` - path mapping that allows for rewriting paths. Can be useful when developing inside a docker container or over SSH. Unset by default. Example for making the extension work in a docker container: `{ "/path/to/hostFolder": "/path/in/dockerContainer" }` 30 | - `phpstan.ignoreErrors` - An array of regular expressions to ignore in error messages. If you find the PHPStan process erroring often because of a warning that can be ignored, put the warning in here and it'll be ignored in the future. 31 | - `phpstan.tmpDir` - Path to the PHPStan TMP directory. Lets PHPStan determine the TMP directory if not set. 32 | 33 | ### Customization 34 | 35 | - `phpstan.enabled` - whether to enable the on-save checker (defaults to `true`) 36 | - `phpstan.enableStatusBar` - whether to show a statusbar entry while performing the check (defaults to `true`) 37 | - `phpstan.showTypeOnHover` - Whether to enable on-hover information. Disable this if you're using a custom PHPStan binary that runs on another filesystem (such as Docker) and you're running into issues (defaults to `false`) 38 | - `phpstan.showProgress` - whether to show the progress bar when performing a single-file check (defaults to `false`) 39 | - `phpstan.checkValidity` - Whether to check the validity of PHP code before checking it with PHPStan. This is recommended only if you have autoSave enabled or for some other reason save syntactically invalid code. PHPStan tends to invalidate its cache when checking an invalid file, leading to a slower experience.'. (defaults to `false`) 40 | 41 | ## FAQ 42 | 43 | ### XDebug-related issues 44 | 45 | If you find XDebug-related issues (such as checks failing with `The Xdebug PHP extension is active, but "--xdebug" is not used` in the output), see these issues: https://github.com/SanderRonde/phpstan-vscode/issues/17, https://github.com/SanderRonde/phpstan-vscode/issues/19. 46 | 47 | ### Does this extension support checking multiple workspaces? 48 | 49 | The extension currently doesn't support checking multiple workspaces. You can use the extension just fine in a multi-workspace project, but only a single PHPStan configuration can be checked. The one that will be checked is configurable using the above configuration options. If you really would like multi-workspace support, feel free to mention so in [this issue](https://github.com/SanderRonde/phpstan-vscode/issues/55) and I might eventually add support for it if there's enough demand. 50 | 51 | ## Development 52 | 53 | First get your dev environment started by running `bun dev`. Note that this expects you to have a few programs installed: 54 | 55 | - `composer` 56 | - `git` 57 | - `bun` 58 | 59 | This command installs all JS and PHP dependencies and ensures you're ready to go for writing a PHPStan extension. 60 | 61 | ### Running the extension 62 | 63 | To run the extension, you can use the `Launch Client` task in VSCode. This will start a new VSCode window with the extension running. Use `Client + Server` to also attach the debugger to the language server. 64 | 65 | ### Building the extension for production 66 | 67 | To build for production or publish, use the VSCode Extension command (`vsce`). `vsce package` will build an installable `.vsix` file. 68 | 69 | ### Good-to-know commands 70 | 71 | The following command will run PHPStan on a demo file, this is handy for testing out changes to the PHPStan plugin that collects hover data. 72 | 73 | `php/vendor/bin/phpstan analyze -c php/config.neon -a php/TreeFetcher.php --debug test/demo/php/DemoClass.php` 74 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanderRonde/phpstan-vscode/298c7b748d6e1c3737248e244390a2e863376d58/bun.lockb -------------------------------------------------------------------------------- /client/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanderRonde/phpstan-vscode/298c7b748d6e1c3737248e244390a2e863376d58/client/bun.lockb -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phpstan-vscode-client", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "build": "esbuild --minify --bundle --outfile=../out/extension.js src/extension.ts --platform=node --external:vscode", 8 | "build-debug": "esbuild --bundle --outfile=../out/extension.js src/extension.ts --platform=node --external:vscode --sourcemap=inline --define:_DEBUG=true", 9 | "build-debug:attach-server": "esbuild --bundle --outfile=../out/extension.js src/extension.ts --platform=node --external:vscode --sourcemap=inline --define:_DEBUG=true --define:_INSPECT_BRK=true" 10 | }, 11 | "devDependencies": { 12 | "@types/vscode": "^1.64.0", 13 | "vscode-languageclient": "^8.0.1" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /client/src/extension.ts: -------------------------------------------------------------------------------- 1 | import { ConfigResolveLanguageStatus } from './notificationReceivers/configResolveLanguageStatus'; 2 | import { 3 | getInstallationConfig, 4 | writeInstallationConfig, 5 | } from './lib/installationConfig'; 6 | import type { 7 | LanguageClientOptions, 8 | ServerOptions, 9 | } from 'vscode-languageclient/node'; 10 | import { 11 | createOutputChannel, 12 | SERVER_PREFIX, 13 | log, 14 | CLIENT_PREFIX, 15 | } from './lib/log'; 16 | import { LanguageClient, TransportKind } from 'vscode-languageclient/node'; 17 | import { debug, initDebugReceiver } from './notificationReceivers/debug'; 18 | import { registerEditorConfigurationListener } from './lib/editorConfig'; 19 | import { DocumentManager } from './notificationSenders/documentManager'; 20 | import { ErrorManager } from './notificationReceivers/errorManager'; 21 | import { ZombieKiller } from './notificationReceivers/zombieKiller'; 22 | import { PHPStanProManager } from './notificationReceivers/pro'; 23 | import { StatusBar } from './notificationReceivers/statusBar'; 24 | import type { ExtensionContext, OutputChannel } from 'vscode'; 25 | import { initRequest } from './lib/requestChannels'; 26 | import { registerListeners } from './lib/commands'; 27 | import { window, workspace } from 'vscode'; 28 | import { INSPECT_BRK } from './lib/dev'; 29 | import * as path from 'path'; 30 | 31 | let client: LanguageClient | null = null; 32 | async function startLanguageServer( 33 | context: ExtensionContext, 34 | outputChannel: OutputChannel 35 | ): Promise { 36 | const serverModule = context.asAbsolutePath(path.join('out', 'server.js')); 37 | const argv = ['--nolazy', '--inspect=6009']; 38 | if (INSPECT_BRK) { 39 | argv.push('--inspect-brk'); 40 | } 41 | const serverOptions: ServerOptions = { 42 | run: { 43 | module: serverModule, 44 | transport: TransportKind.ipc, 45 | }, 46 | debug: { 47 | module: serverModule, 48 | transport: TransportKind.ipc, 49 | options: { 50 | execArgv: argv, 51 | }, 52 | }, 53 | }; 54 | const clientOptions: LanguageClientOptions = { 55 | outputChannel, 56 | documentSelector: [ 57 | { 58 | scheme: 'file', 59 | language: 'php', 60 | }, 61 | ], 62 | synchronize: { 63 | fileEvents: workspace.createFileSystemWatcher( 64 | '*.php', 65 | false, 66 | false, 67 | true 68 | ), 69 | }, 70 | }; 71 | 72 | client = new LanguageClient( 73 | 'phpstan', 74 | 'PHPStan Language Server', 75 | serverOptions, 76 | clientOptions 77 | ); 78 | 79 | await client.start(); 80 | return client; 81 | } 82 | 83 | export async function activate(context: ExtensionContext): Promise { 84 | log(context, CLIENT_PREFIX, 'Initializing PHPStan extension'); 85 | debug('lifecycle', 'Initializing PHPStan extension'); 86 | const outputChannel = createOutputChannel(); 87 | 88 | const client = await startLanguageServer(context, outputChannel); 89 | initDebugReceiver(client); 90 | const statusBar = new StatusBar(context, client); 91 | const watcher = new DocumentManager(client); 92 | const errorManager = new ErrorManager(client); 93 | const proManager = new PHPStanProManager(client); 94 | const zombieKiller = new ZombieKiller(client, context); 95 | const configResolveLanguageStatus = new ConfigResolveLanguageStatus(client); 96 | 97 | registerListeners(context, client, errorManager, proManager, outputChannel); 98 | registerEditorConfigurationListener(context, client); 99 | context.subscriptions.push( 100 | statusBar, 101 | watcher, 102 | errorManager, 103 | proManager, 104 | zombieKiller, 105 | configResolveLanguageStatus, 106 | outputChannel 107 | ); 108 | 109 | let wasReady = false; 110 | const startedAt = Date.now(); 111 | context.subscriptions.push( 112 | client.onRequest(initRequest, ({ ready }) => { 113 | if (ready) { 114 | if (!wasReady) { 115 | // First time it's ready, start watching 116 | log(context, SERVER_PREFIX, 'Language server started'); 117 | void watcher.watch(); 118 | } else { 119 | // Language server was already alive but then died 120 | // and restarted. Clear local state that depends 121 | // on language server. 122 | log(context, SERVER_PREFIX, 'Language server restarted...'); 123 | statusBar.clearAllRunning(); 124 | } 125 | wasReady = true; 126 | } 127 | 128 | return Promise.resolve({ 129 | extensionPath: context.extensionUri.toString(), 130 | startedAt: startedAt, 131 | }); 132 | }) 133 | ); 134 | log(context, CLIENT_PREFIX, 'Initializing done'); 135 | log(context, CLIENT_PREFIX, 'Showing one-time messages (if needed)'); 136 | const installationConfig = await getInstallationConfig(context); 137 | const version = (context.extension.packageJSON as { version: string }) 138 | .version; 139 | if ( 140 | installationConfig.version === '2.2.26' && 141 | installationConfig.version !== version 142 | ) { 143 | // Updated! Show message 144 | void window.showInformationMessage( 145 | 'PHPStan extension updated! Now always checks full project instead of a single file, which ensures it utilizes the cache. Uncached checks may take longer but performance, completeness & UX is better. Let me know if you have any feedback!' 146 | ); 147 | } 148 | await writeInstallationConfig(context, { 149 | ...installationConfig, 150 | version, 151 | }); 152 | } 153 | 154 | export function deactivate(): Thenable | undefined { 155 | if (!client) { 156 | return undefined; 157 | } 158 | return client.stop(); 159 | } 160 | -------------------------------------------------------------------------------- /client/src/lib/commands.ts: -------------------------------------------------------------------------------- 1 | import { 2 | commandNotification, 3 | watcherNotification, 4 | } from './notificationChannels'; 5 | import type { ErrorManager } from '../notificationReceivers/errorManager'; 6 | import type { PHPStanProManager } from '../notificationReceivers/pro'; 7 | import { commands, Commands } from '../../../shared/commands/defs'; 8 | // eslint-disable-next-line node/no-extraneous-import 9 | import { autoRegisterCommand } from 'vscode-generate-package-json'; 10 | import type { LanguageClient } from 'vscode-languageclient/node'; 11 | import { getDebugData } from '../notificationReceivers/debug'; 12 | import { getEditorConfiguration } from './editorConfig'; 13 | import { showError } from './errorUtil'; 14 | 15 | import { launchSetup } from './setup'; 16 | import * as vscode from 'vscode'; 17 | 18 | export function registerListeners( 19 | context: vscode.ExtensionContext, 20 | client: LanguageClient, 21 | errorManager: ErrorManager, 22 | phpstanProManager: PHPStanProManager, 23 | outputChannel: vscode.OutputChannel 24 | ): void { 25 | context.subscriptions.push( 26 | autoRegisterCommand( 27 | Commands.SCAN_FILE_FOR_ERRORS, 28 | async () => { 29 | const editorConfig = getEditorConfiguration(); 30 | if (!editorConfig.get('phpstan.singleFileMode')) { 31 | showError( 32 | 'Please enable single-file mode in the settings to scan a single file. Instead use "Scan project for errors" to scan the whole project.' 33 | ); 34 | return; 35 | } 36 | 37 | const doc = vscode.window.activeTextEditor?.document; 38 | if (doc) { 39 | if (doc.languageId !== 'php') { 40 | showError('Only PHP files can be scanned for errors'); 41 | return; 42 | } 43 | 44 | await client.sendNotification(watcherNotification, { 45 | operation: 'check', 46 | file: { 47 | content: doc.getText(), 48 | uri: doc.uri.toString(), 49 | languageId: doc.languageId, 50 | }, 51 | }); 52 | } 53 | }, 54 | commands 55 | ) 56 | ); 57 | 58 | context.subscriptions.push( 59 | autoRegisterCommand( 60 | Commands.SCAN_PROJECT, 61 | async () => { 62 | await client.sendNotification(watcherNotification, { 63 | operation: 'checkAllProjects', 64 | }); 65 | }, 66 | commands 67 | ) 68 | ); 69 | context.subscriptions.push( 70 | autoRegisterCommand( 71 | Commands.SCAN_CURRENT_PROJECT, 72 | async () => { 73 | await client.sendNotification(watcherNotification, { 74 | operation: 'checkProject', 75 | file: vscode.window.activeTextEditor 76 | ? { 77 | content: 78 | vscode.window.activeTextEditor.document.getText(), 79 | uri: vscode.window.activeTextEditor.document.uri.toString(), 80 | languageId: 81 | vscode.window.activeTextEditor.document 82 | .languageId, 83 | } 84 | : null, 85 | }); 86 | }, 87 | commands 88 | ) 89 | ); 90 | 91 | context.subscriptions.push( 92 | autoRegisterCommand( 93 | Commands.NEXT_ERROR, 94 | () => { 95 | return errorManager.jumpToError('next'); 96 | }, 97 | commands 98 | ) 99 | ); 100 | 101 | context.subscriptions.push( 102 | autoRegisterCommand( 103 | Commands.PREVIOUS_ERROR, 104 | () => { 105 | return errorManager.jumpToError('prev'); 106 | }, 107 | commands 108 | ) 109 | ); 110 | 111 | context.subscriptions.push( 112 | autoRegisterCommand( 113 | Commands.OPEN_PHPSTAN_PRO, 114 | () => { 115 | if (!phpstanProManager.port) { 116 | void vscode.window.showErrorMessage( 117 | 'PHPStan Pro is not running' 118 | ); 119 | return; 120 | } 121 | void vscode.env.openExternal( 122 | vscode.Uri.parse( 123 | `http://127.0.0.1:${phpstanProManager.port}` 124 | ) 125 | ); 126 | }, 127 | commands 128 | ) 129 | ); 130 | 131 | context.subscriptions.push( 132 | autoRegisterCommand( 133 | Commands.RELOAD, 134 | async () => { 135 | const doc = vscode.window.activeTextEditor?.document; 136 | if (doc) { 137 | if (doc.languageId !== 'php') { 138 | showError('Only PHP files can be scanned for errors'); 139 | return; 140 | } 141 | 142 | await client.sendNotification(watcherNotification, { 143 | operation: 'clear', 144 | }); 145 | await client.sendNotification(watcherNotification, { 146 | operation: 'check', 147 | file: { 148 | content: doc.getText(), 149 | uri: doc.uri.toString(), 150 | languageId: doc.languageId, 151 | }, 152 | }); 153 | } 154 | }, 155 | commands 156 | ) 157 | ); 158 | 159 | context.subscriptions.push( 160 | autoRegisterCommand( 161 | Commands.LAUNCH_SETUP, 162 | () => launchSetup(client), 163 | commands 164 | ) 165 | ); 166 | 167 | context.subscriptions.push( 168 | autoRegisterCommand( 169 | Commands.SHOW_OUTPUT_CHANNEL, 170 | () => outputChannel.show(), 171 | commands 172 | ) 173 | ); 174 | 175 | context.subscriptions.push( 176 | client.onNotification( 177 | commandNotification, 178 | ({ commandArgs, commandName }) => { 179 | void vscode.commands.executeCommand( 180 | commandName, 181 | ...commandArgs 182 | ); 183 | } 184 | ) 185 | ); 186 | 187 | context.subscriptions.push( 188 | autoRegisterCommand( 189 | Commands.CLEAR_ERRORS, 190 | () => errorManager.clearErrors(), 191 | commands 192 | ) 193 | ); 194 | 195 | context.subscriptions.push( 196 | autoRegisterCommand( 197 | Commands.DOWNLOAD_DEBUG_DATA, 198 | async () => { 199 | const debugData = getDebugData(); 200 | const json = JSON.stringify(debugData, null, '\t'); 201 | const timestamp = Date.now(); 202 | const uri = vscode.Uri.joinPath( 203 | vscode.workspace.workspaceFolders?.[0]?.uri ?? 204 | vscode.Uri.file(''), 205 | `phpstan-vscode-debug-${timestamp}.json` 206 | ); 207 | await vscode.workspace.fs.writeFile(uri, Buffer.from(json)); 208 | 209 | await vscode.commands.executeCommand('vscode.open', uri); 210 | }, 211 | commands 212 | ) 213 | ); 214 | } 215 | -------------------------------------------------------------------------------- /client/src/lib/dev.ts: -------------------------------------------------------------------------------- 1 | declare const _INSPECT_BRK: boolean | undefined; 2 | 3 | export const INSPECT_BRK = 4 | typeof _INSPECT_BRK === 'undefined' ? false : _INSPECT_BRK; 5 | -------------------------------------------------------------------------------- /client/src/lib/editorConfig.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line node/no-extraneous-import 2 | import type { TypedWorkspaceConfiguration } from 'vscode-generate-package-json/dist/types/src/configuration'; 3 | 4 | import type { 5 | ConfigSettings, 6 | ExternalConfigSettings, 7 | } from '../../../shared/config'; 8 | import type { LanguageClient } from 'vscode-languageclient/node'; 9 | import { watcherNotification } from './notificationChannels'; 10 | import { config } from '../../../shared/commands/defs'; 11 | import type { ExtensionContext } from 'vscode'; 12 | import { window, workspace } from 'vscode'; 13 | import { CLIENT_PREFIX, log } from './log'; 14 | 15 | export function getEditorConfiguration(): TypedWorkspaceConfiguration< 16 | ConfigSettings & 17 | ExternalConfigSettings & { 18 | 'files.exclude'?: Record; 19 | 'search.exclude'?: Record; 20 | } 21 | > { 22 | const document = window.activeTextEditor?.document; 23 | 24 | if (document) { 25 | return workspace.getConfiguration(undefined, document.uri); 26 | } 27 | 28 | return workspace.getConfiguration(); 29 | } 30 | 31 | export function registerEditorConfigurationListener( 32 | context: ExtensionContext, 33 | client: LanguageClient 34 | ): void { 35 | const editorConfig = getEditorConfiguration(); 36 | const configValues: Record = {}; 37 | for (const key in config) { 38 | configValues[key] = editorConfig.get(key as keyof ConfigSettings); 39 | } 40 | log( 41 | context, 42 | CLIENT_PREFIX, 43 | 'Starting extension with configuration:', 44 | JSON.stringify(configValues, null, '\t') 45 | ); 46 | 47 | workspace.onDidChangeConfiguration(async (e) => { 48 | if (!e.affectsConfiguration('phpstan')) { 49 | return; 50 | } 51 | 52 | await client.sendNotification(watcherNotification, { 53 | operation: 'onConfigChange', 54 | file: null, 55 | }); 56 | 57 | if (e.affectsConfiguration('phpstan.paths')) { 58 | const editorConfig = getEditorConfiguration(); 59 | const paths = editorConfig.get('phpstan.paths'); 60 | if ( 61 | (editorConfig.get('phpstan.showTypeOnHover') || 62 | editorConfig.get('phpstan.enableLanguageServer')) && 63 | Object.keys(paths).length > 0 64 | ) { 65 | await window.showWarningMessage( 66 | 'On-hover type information is disabled when the paths setting is being used' 67 | ); 68 | } 69 | } else if ( 70 | e.affectsConfiguration('phpstan.pro') || 71 | e.affectsConfiguration('phpstan.proTmpDir') || 72 | (e.affectsConfiguration('phpstan.enabled') && 73 | getEditorConfiguration().get('phpstan.pro')) 74 | ) { 75 | await window.showInformationMessage( 76 | 'Please reload your editor for changes to the PHPStan Pro configuration to take effect' 77 | ); 78 | } 79 | }); 80 | } 81 | -------------------------------------------------------------------------------- /client/src/lib/errorUtil.ts: -------------------------------------------------------------------------------- 1 | import { window } from 'vscode'; 2 | 3 | interface ErrorOption { 4 | title: string; 5 | callback: () => void; 6 | } 7 | 8 | export function showError(message: string, options?: ErrorOption[]): void { 9 | void window 10 | .showErrorMessage(message, ...(options || []).map((o) => o.title)) 11 | .then((choice) => { 12 | if (!options || !choice) { 13 | return; 14 | } 15 | 16 | const match = options.find((o) => o.title === choice); 17 | match?.callback(); 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /client/src/lib/files.ts: -------------------------------------------------------------------------------- 1 | import { getEditorConfiguration } from './editorConfig'; 2 | import { workspace } from 'vscode'; 3 | import type { Uri } from 'vscode'; 4 | 5 | export function findFiles(pattern: string): Thenable { 6 | const editorConfig = getEditorConfiguration(); 7 | const excludes = new Set(['**/vendor/**']); 8 | const excludeFiles = editorConfig.get('files.exclude'); 9 | for (const key in excludeFiles) { 10 | if (excludeFiles[key]) { 11 | excludes.add(key); 12 | } 13 | } 14 | const excludeSearch = editorConfig.get('search.exclude'); 15 | for (const key in excludeSearch) { 16 | if (excludeSearch[key]) { 17 | excludes.add(key); 18 | } 19 | } 20 | return workspace.findFiles(pattern, `{${[...excludes].join(',')}}`); 21 | } 22 | -------------------------------------------------------------------------------- /client/src/lib/installationConfig.ts: -------------------------------------------------------------------------------- 1 | import type { ExtensionContext } from 'vscode'; 2 | import * as fs from 'fs/promises'; 3 | 4 | interface InstallationConfigFormat { 5 | version?: string; 6 | } 7 | 8 | async function readOrCreateInstallationConfig( 9 | context: ExtensionContext 10 | ): Promise { 11 | const filePath = context.asAbsolutePath('user_config.json'); 12 | try { 13 | return await fs.readFile(filePath, 'utf8'); 14 | } catch (e) { 15 | const fileContent = JSON.stringify({}); 16 | await fs.writeFile(filePath, fileContent, 'utf8'); 17 | return fileContent; 18 | } 19 | } 20 | 21 | export async function getInstallationConfig( 22 | context: ExtensionContext 23 | ): Promise { 24 | const content = await readOrCreateInstallationConfig(context); 25 | return JSON.parse(content) as InstallationConfigFormat; 26 | } 27 | 28 | export async function writeInstallationConfig( 29 | context: ExtensionContext, 30 | installationConfig: InstallationConfigFormat 31 | ): Promise { 32 | await fs.writeFile( 33 | context.asAbsolutePath('user_config.json'), 34 | JSON.stringify(installationConfig), 35 | 'utf8' 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /client/src/lib/log.ts: -------------------------------------------------------------------------------- 1 | import type { ExtensionContext, OutputChannel } from 'vscode'; 2 | import { ExtensionMode, window } from 'vscode'; 3 | 4 | let channel: OutputChannel | null; 5 | 6 | export function createOutputChannel(): OutputChannel { 7 | channel = window.createOutputChannel('PHPStan'); 8 | return channel; 9 | } 10 | 11 | type Prefix = string & { 12 | __isPrefix: true; 13 | }; 14 | 15 | export function log( 16 | context: ExtensionContext, 17 | prefix: Prefix, 18 | ...data: string[] 19 | ): void { 20 | data = [`[${new Date().toLocaleString()}]`, prefix, ...data]; 21 | if (context.extensionMode === ExtensionMode.Development) { 22 | console.log(data.join(' ')); 23 | } 24 | if (channel) { 25 | channel.appendLine(data.join(' ')); 26 | } 27 | } 28 | 29 | export const STATUS_BAR_PREFIX = '[status-bar]' as Prefix; 30 | export const CLIENT_PREFIX = '[client]' as Prefix; 31 | export const SERVER_PREFIX = '[server]' as Prefix; 32 | export const PROCESS_SPAWNER_PREFIX = '[process-spawner]' as Prefix; 33 | -------------------------------------------------------------------------------- /client/src/lib/multiStepInput.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | QuickPickItem, 3 | Disposable, 4 | QuickInputButton, 5 | QuickInput, 6 | } from 'vscode'; 7 | import { window, QuickInputButtons } from 'vscode'; 8 | 9 | export type InputStep = (input: MultiStepInput) => Thenable; 10 | 11 | class InputFlowAction { 12 | private constructor() {} 13 | public static Back = new InputFlowAction(); 14 | public static Cancel = new InputFlowAction(); 15 | public static Resume = new InputFlowAction(); 16 | } 17 | 18 | interface MultiStepInputParameters { 19 | title: string; 20 | step?: number; 21 | totalSteps?: number; 22 | ignoreFocusOut?: boolean; 23 | shouldResume?: () => Thenable; 24 | buttons?: QuickInputButton[]; 25 | placeholder?: string; 26 | } 27 | 28 | interface InputBoxParameters extends MultiStepInputParameters { 29 | value: string; 30 | prompt: string; 31 | password?: boolean; 32 | validate: (value: string) => Promise; 33 | shouldValidateInitially?: boolean; 34 | } 35 | 36 | export interface QuickPickParameters 37 | extends MultiStepInputParameters { 38 | matchOnDescription?: boolean; 39 | matchOnDetail?: boolean; 40 | canPickMany?: boolean; 41 | items: T[]; 42 | activeItem?: T; 43 | } 44 | 45 | export class MultiStepInput { 46 | private current?: QuickInput; 47 | private steps: InputStep[] = []; 48 | 49 | public static async run(start: InputStep): Promise { 50 | const input = new MultiStepInput(); 51 | return input.stepThrough(start); 52 | } 53 | 54 | public get currentStepNumber(): number { 55 | return this.steps.length; 56 | } 57 | 58 | private async stepThrough(start: InputStep): Promise { 59 | let step: InputStep | void = start; 60 | let inputCompleted = true; 61 | while (step) { 62 | this.steps.push(step); 63 | if (this.current) { 64 | this.current.enabled = false; 65 | this.current.busy = true; 66 | } 67 | try { 68 | step = await step(this); 69 | } catch (err) { 70 | if (err === InputFlowAction.Back) { 71 | this.steps.pop(); 72 | step = this.steps.pop(); 73 | } else if (err === InputFlowAction.Resume) { 74 | step = this.steps.pop(); 75 | } else if (err === InputFlowAction.Cancel) { 76 | step = undefined; 77 | inputCompleted = false; 78 | } else { 79 | throw err; 80 | } 81 | } 82 | } 83 | if (this.current) { 84 | this.current.dispose(); 85 | } 86 | return inputCompleted; 87 | } 88 | 89 | public redoLastStep(): void { 90 | throw InputFlowAction.Back; 91 | } 92 | 93 | private async _showInputBox

({ 94 | title, 95 | step, 96 | totalSteps, 97 | value, 98 | prompt, 99 | placeholder, 100 | ignoreFocusOut, 101 | password, 102 | validate, 103 | buttons, 104 | shouldResume, 105 | shouldValidateInitially, 106 | }: P): Promise { 107 | const disposables: Disposable[] = []; 108 | try { 109 | return await new Promise( 110 | (resolve, reject) => { 111 | const input = window.createInputBox(); 112 | input.title = title; 113 | input.step = step ?? this.currentStepNumber; 114 | input.totalSteps = totalSteps; 115 | input.value = value || ''; 116 | input.prompt = prompt; 117 | input.placeholder = placeholder; 118 | input.password = !!password; 119 | input.ignoreFocusOut = !!ignoreFocusOut; 120 | input.buttons = [ 121 | ...(this.steps.length > 1 122 | ? [QuickInputButtons.Back] 123 | : []), 124 | ...(buttons || []), 125 | ]; 126 | 127 | if (shouldValidateInitially) { 128 | void (async () => { 129 | input.enabled = false; 130 | input.busy = true; 131 | input.validationMessage = undefined; 132 | const validationMessage = await validate( 133 | input.value 134 | ); 135 | input.validationMessage = validationMessage; 136 | input.enabled = true; 137 | input.busy = false; 138 | })(); 139 | } 140 | 141 | disposables.push( 142 | input.onDidTriggerButton((item) => { 143 | if (item === QuickInputButtons.Back) { 144 | reject(InputFlowAction.Back); 145 | } else { 146 | resolve(item); 147 | } 148 | }), 149 | input.onDidAccept(async () => { 150 | const value = input.value; 151 | input.enabled = false; 152 | input.busy = true; 153 | 154 | input.validationMessage = undefined; 155 | const validationMessage = await validate(value); 156 | input.validationMessage = validationMessage; 157 | if (!validationMessage) { 158 | resolve(value); 159 | } 160 | input.enabled = true; 161 | input.busy = false; 162 | }), 163 | input.onDidHide(async () => { 164 | try { 165 | reject( 166 | shouldResume && (await shouldResume()) 167 | ? InputFlowAction.Resume 168 | : InputFlowAction.Cancel 169 | ); 170 | } catch (errorInShouldResume) { 171 | reject(errorInShouldResume); 172 | } 173 | }) 174 | ); 175 | 176 | if (this.current) { 177 | this.current.dispose(); 178 | } 179 | this.current = input; 180 | setTimeout(() => input.show(), 5); 181 | } 182 | ); 183 | } finally { 184 | disposables.forEach((d) => void d.dispose()); 185 | } 186 | } 187 | 188 | public async showInputBox

>( 189 | options: P 190 | ): Promise { 191 | return this._showInputBox(options) as Promise; 192 | } 193 | 194 | public async showInputBoxWithButton

( 195 | options: P 196 | ): Promise[number]> { 197 | return this._showInputBox(options); 198 | } 199 | 200 | public async showQuickPick< 201 | T extends QuickPickItem, 202 | P extends QuickPickParameters, 203 | >(options: P, acceptText: true): Promise; 204 | public async showQuickPick< 205 | T extends QuickPickItem, 206 | P extends QuickPickParameters, 207 | >(options: P, acceptText?: boolean): Promise; 208 | public async showQuickPick< 209 | T extends QuickPickItem, 210 | P extends QuickPickParameters, 211 | >(options: P, acceptText: boolean): Promise; 212 | public async showQuickPick< 213 | T extends QuickPickItem, 214 | P extends QuickPickParameters, 215 | >( 216 | { 217 | title, 218 | step, 219 | totalSteps, 220 | items, 221 | activeItem, 222 | placeholder, 223 | ignoreFocusOut, 224 | matchOnDescription, 225 | matchOnDetail, 226 | canPickMany, 227 | buttons, 228 | shouldResume, 229 | }: P, 230 | acceptText: boolean = false 231 | ): Promise { 232 | const disposables: Disposable[] = []; 233 | try { 234 | return await new Promise((resolve, reject) => { 235 | const input = window.createQuickPick(); 236 | input.title = title; 237 | input.step = step ?? this.currentStepNumber; 238 | input.totalSteps = totalSteps; 239 | input.placeholder = placeholder; 240 | input.ignoreFocusOut = !!ignoreFocusOut; 241 | input.matchOnDescription = !!matchOnDescription; 242 | input.matchOnDetail = !!matchOnDetail; 243 | input.canSelectMany = !!canPickMany; 244 | input.items = items; 245 | if (activeItem) { 246 | input.activeItems = [activeItem]; 247 | } 248 | input.buttons = [ 249 | ...(this.steps.length > 1 ? [QuickInputButtons.Back] : []), 250 | ...(buttons || []), 251 | ]; 252 | disposables.push( 253 | input.onDidTriggerButton((item) => { 254 | if (item === QuickInputButtons.Back) { 255 | reject(InputFlowAction.Back); 256 | } else { 257 | resolve(item); 258 | } 259 | }), 260 | input.onDidAccept(() => { 261 | if (input.activeItems[0]) { 262 | resolve(input.activeItems[0]); 263 | } else if (acceptText) { 264 | resolve(input.value); 265 | } else { 266 | // Ignore 267 | } 268 | }), 269 | input.onDidHide(async () => { 270 | try { 271 | reject( 272 | shouldResume && (await shouldResume()) 273 | ? InputFlowAction.Resume 274 | : InputFlowAction.Cancel 275 | ); 276 | } catch (errorInShouldResume) { 277 | reject(errorInShouldResume); 278 | } 279 | }) 280 | ); 281 | 282 | if (this.current) { 283 | this.current.dispose(); 284 | } 285 | this.current = input; 286 | setTimeout(() => input.show(), 5); 287 | }); 288 | } finally { 289 | disposables.forEach((d) => void d.dispose()); 290 | } 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /client/src/lib/notificationChannels.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CommandNotificationType, 3 | DebugNotificationType, 4 | ErrorNotificationType, 5 | PHPStanProNotificationType, 6 | ProcessNotificationType, 7 | StatusBarNotificationType, 8 | WatcherNotificationType, 9 | } from '../../../shared/notificationChannels'; 10 | import { NotificationChannel } from '../../../shared/notificationChannels'; 11 | import { NotificationType } from 'vscode-languageclient'; 12 | 13 | export const watcherNotification = 14 | new NotificationType(NotificationChannel.WATCHER); 15 | 16 | export const commandNotification = 17 | new NotificationType(NotificationChannel.COMMAND); 18 | 19 | export const statusBarNotification = 20 | new NotificationType( 21 | NotificationChannel.STATUS_BAR 22 | ); 23 | 24 | export const errorNotification = new NotificationType( 25 | NotificationChannel.ERROR 26 | ); 27 | 28 | export const processNotification = 29 | new NotificationType(NotificationChannel.SPAWNER); 30 | 31 | export const phpstanProNotification = 32 | new NotificationType( 33 | NotificationChannel.PHPSTAN_PRO 34 | ); 35 | export const debugNotification = new NotificationType( 36 | NotificationChannel.DEBUG 37 | ); 38 | -------------------------------------------------------------------------------- /client/src/lib/requestChannels.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ConfigResolveRequestType, 3 | FindFilesRequestType, 4 | InitRequestType, 5 | TestRunRequestType, 6 | } from '../../../shared/requestChannels'; 7 | import { RequestChannel } from '../../../shared/requestChannels'; 8 | import { RequestType } from 'vscode-languageclient'; 9 | 10 | export const initRequest = new RequestType< 11 | InitRequestType['request'], 12 | InitRequestType['response'], 13 | InitRequestType['error'] 14 | >(RequestChannel.INIT); 15 | 16 | export const testRunRequest = new RequestType< 17 | TestRunRequestType['request'], 18 | TestRunRequestType['response'], 19 | TestRunRequestType['error'] 20 | >(RequestChannel.TEST_RUN); 21 | 22 | export const configResolveRequest = new RequestType< 23 | ConfigResolveRequestType['request'], 24 | ConfigResolveRequestType['response'], 25 | ConfigResolveRequestType['error'] 26 | >(RequestChannel.CONFIG_RESOLVE); 27 | 28 | export const findFilesRequest = new RequestType< 29 | FindFilesRequestType['request'], 30 | FindFilesRequestType['response'], 31 | FindFilesRequestType['error'] 32 | >(RequestChannel.FIND_FILES); 33 | -------------------------------------------------------------------------------- /client/src/notificationReceivers/configResolveLanguageStatus.ts: -------------------------------------------------------------------------------- 1 | import { 2 | languages, 3 | type Disposable, 4 | window, 5 | CancellationTokenSource, 6 | } from 'vscode'; 7 | import { configResolveRequest, findFilesRequest } from '../lib/requestChannels'; 8 | import type { FindFilesRequestType } from '../../../shared/requestChannels'; 9 | import type { LanguageClient } from 'vscode-languageclient/node'; 10 | import { Commands } from '../../../shared/commands/defs'; 11 | import { findFiles } from '../lib/files'; 12 | import type { Command } from 'vscode'; 13 | import { Uri } from 'vscode'; 14 | import path from 'path'; 15 | 16 | export class ConfigResolveLanguageStatus implements Disposable { 17 | private _disposables: Disposable[] = []; 18 | private _languageStatus = languages.createLanguageStatusItem( 19 | 'phpstan.languageStatusItem', 20 | [{ language: 'php' }, { pattern: '**/*.neon' }] 21 | ); 22 | private _outstandingTokens = new Set(); 23 | 24 | public constructor(private readonly _client: LanguageClient) { 25 | this._languageStatus.name = 'PHPStan'; 26 | this._disposables.push(this._languageStatus); 27 | 28 | this._disposables.push( 29 | _client.onRequest( 30 | findFilesRequest, 31 | async (params): Promise => { 32 | return { 33 | files: (await findFiles(params.pattern)).map((file) => 34 | file.toString() 35 | ), 36 | }; 37 | } 38 | ) 39 | ); 40 | this._disposables.push( 41 | window.onDidChangeActiveTextEditor((editor) => { 42 | this._outstandingTokens.forEach((token) => token.cancel()); 43 | this._outstandingTokens.clear(); 44 | 45 | if (!editor) { 46 | // Should not be visible 47 | this._setStatus({ 48 | text: 'PHPStan resolving config...', 49 | command: undefined, 50 | busy: true, 51 | }); 52 | return; 53 | } 54 | void this._update(editor.document.uri); 55 | }) 56 | ); 57 | 58 | if (window.activeTextEditor) { 59 | void this._update(window.activeTextEditor.document.uri); 60 | } 61 | } 62 | 63 | private _setStatus(config: { 64 | text: string; 65 | command: Command | undefined; 66 | busy: boolean; 67 | }): void { 68 | this._languageStatus.text = config.text; 69 | this._languageStatus.command = config.command; 70 | this._languageStatus.busy = config.busy ?? false; 71 | } 72 | 73 | private async _update(uri: Uri): Promise { 74 | const cancelToken = new CancellationTokenSource(); 75 | this._outstandingTokens.add(cancelToken); 76 | 77 | this._setStatus({ 78 | text: 'PHPStan resolving config...', 79 | command: undefined, 80 | busy: true, 81 | }); 82 | const result = await this._client.sendRequest( 83 | configResolveRequest, 84 | { 85 | uri: uri.toString(), 86 | }, 87 | cancelToken.token 88 | ); 89 | 90 | this._languageStatus.busy = false; 91 | if (result.uri) { 92 | const configUri = Uri.parse(result.uri); 93 | this._setStatus({ 94 | text: path.basename(configUri.fsPath), 95 | busy: false, 96 | command: { 97 | title: 'Open config file', 98 | command: 'vscode.open', 99 | arguments: [configUri], 100 | }, 101 | }); 102 | } else { 103 | this._setStatus({ 104 | text: 'PHPStan (no config found)', 105 | busy: false, 106 | command: { 107 | title: 'Show output channel', 108 | command: Commands.SHOW_OUTPUT_CHANNEL, 109 | arguments: [], 110 | }, 111 | }); 112 | } 113 | } 114 | 115 | public dispose(): void { 116 | this._disposables.forEach((disposable) => void disposable.dispose()); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /client/src/notificationReceivers/debug.ts: -------------------------------------------------------------------------------- 1 | import type { LanguageClient } from 'vscode-languageclient/node'; 2 | 3 | import { debugNotification } from '../lib/notificationChannels'; 4 | import type { Disposable } from 'vscode'; 5 | 6 | const sessionDebugData: { 7 | type: string; 8 | data: unknown[]; 9 | timestamp: string; 10 | timestampMs: number; 11 | }[] = []; 12 | 13 | export function debug(type: string, ...data: unknown[]): void { 14 | const now = new Date(); 15 | sessionDebugData.push({ 16 | type, 17 | data, 18 | timestamp: now.toISOString(), 19 | timestampMs: now.getTime(), 20 | }); 21 | } 22 | 23 | export function initDebugReceiver(client: LanguageClient): Disposable { 24 | return client.onNotification(debugNotification, (params) => { 25 | debug(params.debugData.type, ...params.debugData.data); 26 | }); 27 | } 28 | 29 | export function getDebugData(): { 30 | type: string; 31 | data: unknown[]; 32 | timestamp: string; 33 | timestampMs: number; 34 | }[] { 35 | return sessionDebugData; 36 | } 37 | 38 | export * from '../../../shared/debug'; 39 | -------------------------------------------------------------------------------- /client/src/notificationReceivers/pro.ts: -------------------------------------------------------------------------------- 1 | import { phpstanProNotification } from '../lib/notificationChannels'; 2 | import type { LanguageClient } from 'vscode-languageclient/node'; 3 | import type { Disposable } from 'vscode'; 4 | import { env, window } from 'vscode'; 5 | import { Uri } from 'vscode'; 6 | 7 | export class PHPStanProManager implements Disposable { 8 | private _disposables: Disposable[] = []; 9 | public port: number | null = null; 10 | 11 | public constructor(client: LanguageClient) { 12 | this._disposables.push( 13 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 14 | client.onNotification(phpstanProNotification, async (message) => { 15 | if (message.type === 'setPort') { 16 | this.port = message.port; 17 | } else if (message.type === 'requireLogin') { 18 | const choice = await window.showInformationMessage( 19 | 'Please log in to PHPStan Pro', 20 | 'Log in' 21 | ); 22 | if (choice === 'Log in') { 23 | if (!this.port) { 24 | void window.showErrorMessage( 25 | 'PHPStan Pro port is unknown' 26 | ); 27 | } else { 28 | void env.openExternal( 29 | Uri.parse(`http://127.0.0.1:${this.port}`) 30 | ); 31 | } 32 | } 33 | } 34 | }) 35 | ); 36 | 37 | // // eslint-disable-next-line @typescript-eslint/no-this-alias 38 | // const self = this; 39 | // if (typeof workspace.registerPortAttributesProvider === 'function') { 40 | // workspace.registerPortAttributesProvider( 41 | // {}, 42 | // new (class implements PortAttributesProvider { 43 | // public providePortAttributes(attributes: { 44 | // port: number; 45 | // pid?: number | undefined; 46 | // commandLine?: string | undefined; 47 | // }): ProviderResult { 48 | // if (attributes.port !== self.port) { 49 | // return undefined; 50 | // } 51 | 52 | // return { 53 | // autoForwardAction: PortAutoForwardAction.Silent, 54 | // }; 55 | // } 56 | // })() 57 | // ); 58 | // } 59 | } 60 | 61 | public dispose(): void { 62 | for (const disposable of this._disposables) { 63 | disposable.dispose(); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /client/src/notificationReceivers/statusBar.ts: -------------------------------------------------------------------------------- 1 | import type { StatusBarProgress } from '../../../shared/notificationChannels'; 2 | import { statusBarNotification } from '../lib/notificationChannels'; 3 | import type { LanguageClient } from 'vscode-languageclient/node'; 4 | import type { Commands } from '../../../shared/commands/defs'; 5 | import { getEditorConfiguration } from '../lib/editorConfig'; 6 | import { OperationStatus } from '../../../shared/statusBar'; 7 | import { assertUnreachable } from '../../../shared/util'; 8 | import { log, STATUS_BAR_PREFIX } from '../lib/log'; 9 | import type { Disposable } from 'vscode'; 10 | import * as vscode from 'vscode'; 11 | import { debug } from './debug'; 12 | 13 | export class StatusBar implements Disposable { 14 | private _runningOperation: { 15 | tooltip: string; 16 | id: number; 17 | } | null = null; 18 | 19 | private readonly _textManager = new TextManager(); 20 | private _fallback: 21 | | { 22 | text: string; 23 | command?: Commands; 24 | } 25 | | undefined = undefined; 26 | private _hideTimeout: ReturnType | undefined; 27 | 28 | public constructor( 29 | private readonly _context: vscode.ExtensionContext, 30 | client: LanguageClient 31 | ) { 32 | this._context.subscriptions.push( 33 | client.onNotification(statusBarNotification, (params) => { 34 | log( 35 | this._context, 36 | STATUS_BAR_PREFIX, 37 | "notification:'", 38 | JSON.stringify(params) 39 | ); 40 | debug('statusBar', { 41 | params, 42 | }); 43 | 44 | if (!getEditorConfiguration().get('phpstan.enableStatusBar')) { 45 | return; 46 | } 47 | 48 | switch (params.type) { 49 | case 'new': 50 | this.startOperation(params.opId, params.tooltip); 51 | break; 52 | case 'progress': 53 | if (!this._runningOperation) { 54 | this.startOperation(params.opId, params.tooltip); 55 | } 56 | if (params.opId === this._runningOperation?.id) { 57 | this.operationProgress( 58 | params.progress, 59 | params.tooltip 60 | ); 61 | } 62 | break; 63 | case 'done': 64 | if (params.opId === this._runningOperation?.id) { 65 | this._completeWithResult( 66 | params.opId, 67 | params.result 68 | ); 69 | } 70 | break; 71 | case 'fallback': 72 | if (params.text === undefined) { 73 | this._fallback = undefined; 74 | } else { 75 | this._fallback = { 76 | text: params.text, 77 | command: params.command, 78 | }; 79 | } 80 | if (!this._runningOperation) { 81 | this._fallbackOrHide(); 82 | } 83 | break; 84 | } 85 | }) 86 | ); 87 | } 88 | 89 | private _showStatusBar(): void { 90 | log(this._context, STATUS_BAR_PREFIX, 'Showing status bar'); 91 | if (!getEditorConfiguration().get('phpstan.enableStatusBar')) { 92 | return; 93 | } 94 | 95 | if (this._hideTimeout) { 96 | clearInterval(this._hideTimeout); 97 | } 98 | this._textManager.setText( 99 | `PHPStan checking... ${TextManager.LOADING_SPIN}` 100 | ); 101 | this._textManager.show(); 102 | } 103 | 104 | private _fallbackOrHide(): void { 105 | if (!this._fallback) { 106 | this._textManager.hide(); 107 | return; 108 | } 109 | 110 | this._textManager.setText(this._fallback.text, this._fallback.command); 111 | this._textManager.show(); 112 | } 113 | 114 | private _completeWithResult( 115 | operationId: number, 116 | result: OperationStatus 117 | ): void { 118 | log( 119 | this._context, 120 | STATUS_BAR_PREFIX, 121 | 'Hiding status bar, last operation result =', 122 | result 123 | ); 124 | if (result === OperationStatus.KILLED) { 125 | this._textManager.setText( 126 | 'PHPStan process killed (timeout)', 127 | this._fallback?.command 128 | ); 129 | } else if (result === OperationStatus.SUCCESS) { 130 | this._textManager.setText( 131 | 'PHPStan checking done', 132 | this._fallback?.command 133 | ); 134 | } else if (result === OperationStatus.ERROR) { 135 | this._textManager.setText( 136 | 'PHPStan checking errored (see log)', 137 | this._fallback?.command 138 | ); 139 | } else if (result !== OperationStatus.CANCELLED) { 140 | assertUnreachable(result); 141 | } 142 | this._textManager.setText( 143 | 'PHPStan checking done', 144 | this._fallback?.command 145 | ); 146 | this._textManager.setTooltips(undefined); 147 | this._hideTimeout = setTimeout( 148 | () => { 149 | this._fallbackOrHide(); 150 | if (this._runningOperation?.id === operationId) { 151 | this._runningOperation = null; 152 | } 153 | }, 154 | result === OperationStatus.ERROR ? 2000 : 500 155 | ); 156 | } 157 | 158 | private startOperation(operationId: number, tooltip: string): void { 159 | this._runningOperation = { 160 | tooltip: tooltip, 161 | id: operationId, 162 | }; 163 | 164 | if (!this._textManager.isShown()) { 165 | this._showStatusBar(); 166 | } 167 | } 168 | 169 | private operationProgress( 170 | progress: StatusBarProgress, 171 | tooltip: string 172 | ): void { 173 | this._textManager.setText( 174 | `PHPStan checking project ${progress.done}/${progress.total} - ${progress.percentage}% ${TextManager.LOADING_SPIN}`, 175 | this._fallback?.command 176 | ); 177 | this._runningOperation!.tooltip = tooltip; 178 | this._textManager.setTooltips(tooltip); 179 | 180 | this._textManager.show(); 181 | } 182 | 183 | public clearAllRunning(): void { 184 | if (this._runningOperation) { 185 | this._runningOperation = null; 186 | } 187 | this._textManager.hide(); 188 | } 189 | 190 | public dispose(): void { 191 | this._fallback = undefined; 192 | this._textManager.dispose(); 193 | } 194 | } 195 | 196 | /** 197 | * When updating the content of the statusbar, the spinning icon will start 198 | * from its initial position. That's kind of ugly so we wait with showing 199 | * the new text until it's restarting the animation. 200 | */ 201 | class TextManager implements Disposable { 202 | public static readonly LOADING_SPIN = '$(loading~spin)'; 203 | private readonly _statusBar = vscode.window.createStatusBarItem( 204 | vscode.StatusBarAlignment.Right, 205 | 1 206 | ); 207 | private _pendingStatusBarText: string | null = null; 208 | private _statusBarInterval: NodeJS.Timeout | null = null; 209 | private _isShown: boolean = false; 210 | 211 | public constructor() { 212 | this._statusBarInterval = setInterval(() => { 213 | this._pushStatusBarText(); 214 | }, 1000); 215 | } 216 | 217 | private _pushStatusBarText(): void { 218 | if (this._pendingStatusBarText) { 219 | this._statusBar.text = this._pendingStatusBarText; 220 | this._pendingStatusBarText = null; 221 | } 222 | } 223 | 224 | public isShown(): boolean { 225 | return this._isShown; 226 | } 227 | 228 | public setText(text: string, command?: Commands): void { 229 | if (command) { 230 | this._statusBar.command = command; 231 | } else { 232 | this._statusBar.command = undefined; 233 | } 234 | 235 | // Queue this new text 236 | this._pendingStatusBarText = text; 237 | if (this._statusBar.text === text) { 238 | // Bug-like thing where we need to set the text explicitly even though 239 | // it was already set to this 240 | this._pushStatusBarText(); 241 | return; 242 | } 243 | if (!text.includes(TextManager.LOADING_SPIN)) { 244 | // Without a spinner the text can be set immediately 245 | this._pushStatusBarText(); 246 | return; 247 | } 248 | if (!this._statusBar.text.includes(TextManager.LOADING_SPIN)) { 249 | // Just went from no spinner to a spinner, push immediately 250 | this._pushStatusBarText(); 251 | return; 252 | } 253 | } 254 | 255 | public setTooltips(tooltip: string | undefined): void { 256 | this._statusBar.tooltip = tooltip; 257 | } 258 | 259 | public hide(): void { 260 | this._isShown = false; 261 | this._statusBar.hide(); 262 | this._pendingStatusBarText = null; 263 | } 264 | 265 | public show(): void { 266 | this._isShown = true; 267 | this._statusBar.show(); 268 | } 269 | 270 | public dispose(): void { 271 | this.hide(); 272 | this._statusBar.dispose(); 273 | if (this._statusBarInterval) { 274 | clearInterval(this._statusBarInterval); 275 | } 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /client/src/notificationReceivers/zombieKiller.ts: -------------------------------------------------------------------------------- 1 | import { processNotification } from '../lib/notificationChannels'; 2 | import type { LanguageClient } from 'vscode-languageclient/node'; 3 | import type { Disposable, ExtensionContext } from 'vscode'; 4 | import { PROCESS_SPAWNER_PREFIX, log } from '../lib/log'; 5 | import { default as psTree } from 'ps-tree'; 6 | 7 | interface ProcessDescriptor { 8 | pid: number; 9 | binStr: string | undefined; 10 | } 11 | 12 | interface RootProcessDescriptor extends ProcessDescriptor { 13 | timeout: number; 14 | children?: ProcessDescriptor[]; 15 | } 16 | 17 | export class ZombieKiller implements Disposable { 18 | private static STORAGE_KEY = 'phpstan.processes.v1'; 19 | private _disposables: Disposable[] = []; 20 | 21 | public constructor( 22 | client: LanguageClient, 23 | private readonly _context: ExtensionContext 24 | ) { 25 | void this._kill(true); 26 | this._disposables.push( 27 | client.onNotification( 28 | processNotification, 29 | ({ pid, children, timeout }) => { 30 | const currentPids = this._context.workspaceState.get< 31 | Record 32 | >(ZombieKiller.STORAGE_KEY, {}); 33 | if (!currentPids[pid]) { 34 | log( 35 | this._context, 36 | PROCESS_SPAWNER_PREFIX, 37 | 'Spawning process', 38 | String(pid), 39 | 'with timeout', 40 | String(timeout) 41 | ); 42 | } 43 | void this._pushPid(pid, children ?? [], timeout); 44 | } 45 | ) 46 | ); 47 | const interval = setInterval(() => void this._kill(), 1000 * 60 * 30); 48 | this._disposables.push({ 49 | dispose: () => clearInterval(interval), 50 | }); 51 | } 52 | 53 | private _killProc(pid: number, binStr?: string): void { 54 | psTree(pid, (err, children) => { 55 | if (err || children.length === 0) { 56 | // No longer exists or something went wrong 57 | return; 58 | } 59 | 60 | children.forEach((proc) => { 61 | if (binStr && proc.COMMAND !== binStr) { 62 | return; 63 | } 64 | 65 | try { 66 | process.kill(Number(proc.PID), 'SIGINT'); 67 | } catch (e) { 68 | process.kill(Number(proc.PID), 'SIGKILL'); 69 | } 70 | }); 71 | }); 72 | } 73 | 74 | private async _kill(killTimeoutless: boolean = false): Promise { 75 | const processes = this._context.workspaceState.get( 76 | ZombieKiller.STORAGE_KEY, 77 | {} 78 | ) as Record; 79 | if (Object.keys(processes).length === 0) { 80 | return; 81 | } 82 | 83 | const toKill: { descriptor: ProcessDescriptor; pid: string }[] = []; 84 | Object.entries(processes).forEach(([pid, descriptor]) => { 85 | if ( 86 | killTimeoutless || 87 | (Date.now() > descriptor.timeout && descriptor.timeout !== 0) 88 | ) { 89 | toKill.push({ 90 | descriptor, 91 | pid, 92 | }); 93 | if (descriptor.children) { 94 | descriptor.children.forEach((child) => { 95 | toKill.push({ 96 | descriptor: child, 97 | pid, 98 | }); 99 | }); 100 | } 101 | } 102 | }); 103 | 104 | const programs: psTree.PS[] = []; 105 | await Promise.all( 106 | toKill.map( 107 | async ({ pid }) => 108 | new Promise((resolve) => { 109 | psTree(Number(pid), (err, children) => { 110 | if (!err) { 111 | programs.push(...children); 112 | } 113 | resolve(); 114 | }); 115 | }) 116 | ) 117 | ); 118 | 119 | for (const { descriptor, pid } of toKill) { 120 | const program = programs.find( 121 | (p) => 122 | p.PID === pid && 123 | (descriptor.binStr === undefined || 124 | p.COMMAND === descriptor.binStr) 125 | ); 126 | if (program) { 127 | void this._killProc(parseInt(pid, 10), program.COMMAND); 128 | } 129 | } 130 | 131 | const newProcesses: Record = {}; 132 | for (const pid in processes) { 133 | if (toKill.some(({ pid: pid2 }) => pid2 === pid)) { 134 | continue; 135 | } 136 | newProcesses[pid] = processes[pid]; 137 | } 138 | void this._context.workspaceState.update( 139 | ZombieKiller.STORAGE_KEY, 140 | newProcesses 141 | ); 142 | } 143 | 144 | private async _pushPid( 145 | pid: number, 146 | children: number[], 147 | timeout: number 148 | ): Promise { 149 | const programs = await new Promise((resolve) => { 150 | psTree(pid, (err, list) => { 151 | if (err) { 152 | resolve([]); 153 | } else { 154 | resolve(list); 155 | } 156 | }); 157 | }); 158 | 159 | const binStr = programs.find((p) => p.PID === String(pid))?.COMMAND; 160 | const childBinStrs = children.map((child) => { 161 | const program = programs.find((p) => p.PID === String(child)); 162 | return program?.COMMAND; 163 | }); 164 | 165 | const targetTime = timeout === 0 ? 0 : Date.now() + timeout; 166 | await this._context.workspaceState.update(ZombieKiller.STORAGE_KEY, { 167 | ...this._context.workspaceState.get(ZombieKiller.STORAGE_KEY, {}), 168 | [pid]: binStr 169 | ? ({ 170 | timeout: targetTime, 171 | binStr, 172 | pid, 173 | children: children.map((pid, i) => ({ 174 | pid: pid, 175 | binStr: childBinStrs[i], 176 | })), 177 | } satisfies RootProcessDescriptor) 178 | : targetTime, 179 | }); 180 | } 181 | 182 | public dispose(): void { 183 | this._disposables.forEach((d) => void d.dispose()); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /client/src/notificationSenders/documentManager.ts: -------------------------------------------------------------------------------- 1 | import type { WatcherNotificationFileData } from '../../../shared/notificationChannels'; 2 | import { debug, sanitizeFilePath } from '../notificationReceivers/debug'; 3 | import { watcherNotification } from '../lib/notificationChannels'; 4 | import type { LanguageClient } from 'vscode-languageclient/node'; 5 | import { getEditorConfiguration } from '../lib/editorConfig'; 6 | import type { Disposable } from 'vscode'; 7 | import { createHash } from 'crypto'; 8 | import * as fs from 'fs/promises'; 9 | import * as vscode from 'vscode'; 10 | type PartialDocument = Pick< 11 | vscode.TextDocument, 12 | 'uri' | 'getText' | 'isDirty' | 'languageId' 13 | >; 14 | 15 | export class DocumentManager implements Disposable { 16 | private _disposables: Disposable[] = []; 17 | private readonly _client: LanguageClient; 18 | 19 | public constructor(client: LanguageClient) { 20 | this._client = client; 21 | } 22 | 23 | private _shouldSyncDocument( 24 | e: PartialDocument, 25 | changes?: readonly vscode.TextDocumentContentChangeEvent[] 26 | ): boolean { 27 | return ( 28 | e.languageId === 'php' && 29 | !e.isDirty && 30 | (!changes || changes.length === 0) && 31 | ['file', 'vscode-vfs', 'git', 'vscode-remote'].includes( 32 | e.uri.scheme 33 | ) 34 | ); 35 | } 36 | 37 | private _isConfigFile(e: PartialDocument): boolean { 38 | if (e.isDirty) { 39 | return false; 40 | } 41 | const configFiles = getEditorConfiguration() 42 | .get('phpstan.configFile') 43 | .split(',') 44 | .map((e) => e.trim()); 45 | for (const configFile of configFiles) { 46 | if (e.uri.fsPath.includes(configFile)) { 47 | return true; 48 | } 49 | } 50 | return false; 51 | } 52 | 53 | private _toSendData(e: PartialDocument): WatcherNotificationFileData { 54 | return { 55 | uri: e.uri.toString(), 56 | content: e.getText(), 57 | languageId: e.languageId, 58 | }; 59 | } 60 | 61 | private async _onDocumentChange( 62 | e: vscode.TextDocumentChangeEvent 63 | ): Promise { 64 | if (this._isConfigFile(e.document)) { 65 | debug('configChange', { 66 | filePath: sanitizeFilePath(e.document.uri.fsPath), 67 | }); 68 | await this._client.sendNotification(watcherNotification, { 69 | operation: 'onConfigChange', 70 | file: this._toSendData(e.document), 71 | }); 72 | } 73 | if (this._shouldSyncDocument(e.document, e.contentChanges)) { 74 | debug('documentChange', { 75 | checking: true, 76 | filePath: sanitizeFilePath(e.document.uri.fsPath), 77 | }); 78 | await this._client.sendNotification(watcherNotification, { 79 | operation: 'change', 80 | file: this._toSendData(e.document), 81 | }); 82 | } 83 | } 84 | 85 | private async _onDocumentSave(e: vscode.TextDocument): Promise { 86 | const fileContents = e.getText(); 87 | const fileContentsHash = createHash('sha256') 88 | .update(fileContents) 89 | .digest('hex'); 90 | const onDiskContents = await fs.readFile(e.uri.fsPath, 'utf-8'); 91 | const onDiskContentsHash = createHash('sha256') 92 | .update(onDiskContents) 93 | .digest('hex'); 94 | if (fileContentsHash !== onDiskContentsHash) { 95 | debug('documentSave', { 96 | filePath: sanitizeFilePath(e.uri.fsPath), 97 | }); 98 | await this._client.sendNotification(watcherNotification, { 99 | operation: 'save', 100 | file: this._toSendData(e), 101 | }); 102 | const postSaveContents = await fs.readFile(e.uri.fsPath, 'utf-8'); 103 | const postSaveContentsHash = createHash('sha256') 104 | .update(postSaveContents) 105 | .digest('hex'); 106 | debug('documentSave', { 107 | filePath: sanitizeFilePath(e.uri.fsPath), 108 | postSaveContentsHash, 109 | fileContentsHash, 110 | onDiskContentsHash, 111 | }); 112 | } 113 | } 114 | 115 | private async _onDocumentActive(e: vscode.TextDocument): Promise { 116 | if (this._shouldSyncDocument(e)) { 117 | debug('documentActive', { 118 | filePath: sanitizeFilePath(e.uri.fsPath), 119 | }); 120 | await this._client.sendNotification(watcherNotification, { 121 | operation: 'setActive', 122 | file: this._toSendData(e), 123 | }); 124 | } 125 | } 126 | 127 | private async _onDocumentOpen( 128 | e: vscode.TextDocument, 129 | check: boolean 130 | ): Promise { 131 | if (this._shouldSyncDocument(e)) { 132 | debug('documentOpen', { 133 | filePath: sanitizeFilePath(e.uri.fsPath), 134 | check, 135 | }); 136 | await this._client.sendNotification(watcherNotification, { 137 | operation: 'open', 138 | file: this._toSendData(e), 139 | check, 140 | }); 141 | } 142 | } 143 | 144 | private async _onDocumentClose(e: PartialDocument): Promise { 145 | if (this._shouldSyncDocument(e)) { 146 | debug('documentClose', { 147 | filePath: sanitizeFilePath(e.uri.fsPath), 148 | }); 149 | await this._client.sendNotification(watcherNotification, { 150 | operation: 'close', 151 | file: this._toSendData(e), 152 | }); 153 | } 154 | } 155 | 156 | public async watch(): Promise { 157 | debug('watch', 'Starting document watch'); 158 | await Promise.all( 159 | vscode.workspace.textDocuments.map((doc) => { 160 | return this._onDocumentOpen(doc, false); 161 | }) 162 | ); 163 | 164 | this._disposables.push( 165 | vscode.window.onDidChangeActiveTextEditor((e) => { 166 | if (e) { 167 | void this._onDocumentActive(e?.document); 168 | } 169 | }) 170 | ); 171 | 172 | this._disposables.push( 173 | vscode.workspace.onDidSaveTextDocument((e) => { 174 | void this._onDocumentSave(e); 175 | }) 176 | ); 177 | 178 | this._disposables.push( 179 | vscode.workspace.onDidSaveTextDocument((e) => { 180 | void this._onDocumentActive(e); 181 | }) 182 | ); 183 | 184 | this._disposables.push( 185 | vscode.workspace.onDidChangeTextDocument((e) => { 186 | void this._onDocumentChange(e); 187 | }) 188 | ); 189 | 190 | this._disposables.push( 191 | vscode.workspace.onDidCloseTextDocument((e) => { 192 | void this._onDocumentClose(e); 193 | }) 194 | ); 195 | 196 | if (vscode.window.activeTextEditor) { 197 | void this._onDocumentActive( 198 | vscode.window.activeTextEditor.document 199 | ); 200 | } 201 | } 202 | 203 | public dispose(): void { 204 | debug('dispose', 'Disposing document manager'); 205 | this._disposables.forEach((d) => void d.dispose()); 206 | this._disposables = []; 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /client/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | # bun ./bun.lockb --hash: E3EA49A425A7EF9D-042d36a5768f7959-1470DA778EF959F0-f30bed6f10b1d8b2 4 | 5 | 6 | "@types/vscode@^1.64.0": 7 | version "1.87.0" 8 | resolved "https://registry.npmjs.org/@types/vscode/-/vscode-1.87.0.tgz" 9 | integrity sha512-y3yYJV2esWr8LNjp3VNbSMWG7Y43jC8pCldG8YwiHGAQbsymkkMMt0aDT1xZIOFM2eFcNiUc+dJMx1+Z0UT8fg== 10 | 11 | balanced-match@^1.0.0: 12 | version "1.0.2" 13 | resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" 14 | integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== 15 | 16 | brace-expansion@^2.0.1: 17 | version "2.0.1" 18 | resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz" 19 | integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== 20 | dependencies: 21 | balanced-match "^1.0.0" 22 | 23 | lru-cache@^6.0.0: 24 | version "6.0.0" 25 | resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz" 26 | integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== 27 | dependencies: 28 | yallist "^4.0.0" 29 | 30 | minimatch@^5.1.0: 31 | version "5.1.6" 32 | resolved "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz" 33 | integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== 34 | dependencies: 35 | brace-expansion "^2.0.1" 36 | 37 | semver@^7.3.7: 38 | version "7.6.0" 39 | resolved "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz" 40 | integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg== 41 | dependencies: 42 | lru-cache "^6.0.0" 43 | 44 | vscode-jsonrpc@8.1.0: 45 | version "8.1.0" 46 | resolved "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.1.0.tgz" 47 | integrity sha512-6TDy/abTQk+zDGYazgbIPc+4JoXdwC8NHU9Pbn4UJP1fehUyZmM4RHp5IthX7A6L5KS30PRui+j+tbbMMMafdw== 48 | 49 | vscode-languageclient@^8.0.1: 50 | version "8.1.0" 51 | resolved "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-8.1.0.tgz" 52 | integrity sha512-GL4QdbYUF/XxQlAsvYWZRV3V34kOkpRlvV60/72ghHfsYFnS/v2MANZ9P6sHmxFcZKOse8O+L9G7Czg0NUWing== 53 | dependencies: 54 | minimatch "^5.1.0" 55 | semver "^7.3.7" 56 | vscode-languageserver-protocol "3.17.3" 57 | 58 | vscode-languageserver-protocol@3.17.3: 59 | version "3.17.3" 60 | resolved "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.3.tgz" 61 | integrity sha512-924/h0AqsMtA5yK22GgMtCYiMdCOtWTSGgUOkgEDX+wk2b0x4sAfLiO4NxBxqbiVtz7K7/1/RgVrVI0NClZwqA== 62 | dependencies: 63 | vscode-jsonrpc "8.1.0" 64 | vscode-languageserver-types "3.17.3" 65 | 66 | vscode-languageserver-types@3.17.3: 67 | version "3.17.3" 68 | resolved "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.3.tgz" 69 | integrity sha512-SYU4z1dL0PyIMd4Vj8YOqFvHu7Hz/enbWtpfnVbJHU4Nd1YNYx8u0ennumc6h48GQNeOLxmwySmnADouT/AuZA== 70 | 71 | yallist@^4.0.0: 72 | version "4.0.0" 73 | resolved "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz" 74 | integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== 75 | -------------------------------------------------------------------------------- /knip.ts: -------------------------------------------------------------------------------- 1 | import type { KnipConfig } from 'knip'; 2 | 3 | const config: KnipConfig = { 4 | entry: ['client/src/extension.ts', 'server/src/server.ts'], 5 | project: ['{client,server}/**/*.{js,ts}'], 6 | exclude: ['devDependencies', 'unlisted', 'binaries', 'unresolved'], 7 | }; 8 | 9 | export default config; 10 | -------------------------------------------------------------------------------- /php/Diagnoser.php: -------------------------------------------------------------------------------- 1 | container->getService('fileFinderAnalyse'); 27 | $fileFinderResult = $fileFinder->findFiles($this->analysedPaths); 28 | $files = $fileFinderResult->getFiles(); 29 | 30 | /** @var PathRoutingParser $pathRoutingParser */ 31 | $pathRoutingParser = $this->container->getService('pathRoutingParser'); 32 | 33 | $pathRoutingParser->setAnalysedFiles($files); 34 | 35 | $currentWorkingDirectoryFileHelper = new FileHelper($this->currentWorkingDirectory); 36 | /** @var StubFilesProvider $stubFilesProvider */ 37 | $stubFilesProvider = $this->container->getByType(StubFilesProvider::class); 38 | $stubFilesExcluder = new FileExcluder($currentWorkingDirectoryFileHelper, $stubFilesProvider->getProjectStubFiles(), true); 39 | 40 | $files = array_values(array_filter($files, static fn(string $file) => !$stubFilesExcluder->isExcludedFromAnalysing($file))); 41 | 42 | return $files; 43 | } 44 | 45 | public function print(Output $output): void { 46 | $output->writeLineFormatted("PHPStanVSCodeDiagnoser:" . json_encode($this->getFiles())); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /php/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sander/php", 3 | "require": { 4 | "phpstan/phpstan": "^1.12", 5 | "nikic/php-parser": "^4.14" 6 | }, 7 | "autoload": { 8 | "psr-4": { 9 | "Sander\\Php\\": "src/" 10 | } 11 | }, 12 | "authors": [ 13 | { 14 | "name": "\"Sander Ronde\"", 15 | "email": "awsdfgvhbjn@gmail.com" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /php/composer.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_readme": [ 3 | "This file locks the dependencies of your project to a known state", 4 | "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", 5 | "This file is @generated automatically" 6 | ], 7 | "content-hash": "480462a92ad3c2bb7346424989941659", 8 | "packages": [ 9 | { 10 | "name": "nikic/php-parser", 11 | "version": "v4.19.1", 12 | "source": { 13 | "type": "git", 14 | "url": "https://github.com/nikic/PHP-Parser.git", 15 | "reference": "4e1b88d21c69391150ace211e9eaf05810858d0b" 16 | }, 17 | "dist": { 18 | "type": "zip", 19 | "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/4e1b88d21c69391150ace211e9eaf05810858d0b", 20 | "reference": "4e1b88d21c69391150ace211e9eaf05810858d0b", 21 | "shasum": "" 22 | }, 23 | "require": { 24 | "ext-tokenizer": "*", 25 | "php": ">=7.1" 26 | }, 27 | "require-dev": { 28 | "ircmaxell/php-yacc": "^0.0.7", 29 | "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" 30 | }, 31 | "bin": [ 32 | "bin/php-parse" 33 | ], 34 | "type": "library", 35 | "extra": { 36 | "branch-alias": { 37 | "dev-master": "4.9-dev" 38 | } 39 | }, 40 | "autoload": { 41 | "psr-4": { 42 | "PhpParser\\": "lib/PhpParser" 43 | } 44 | }, 45 | "notification-url": "https://packagist.org/downloads/", 46 | "license": [ 47 | "BSD-3-Clause" 48 | ], 49 | "authors": [ 50 | { 51 | "name": "Nikita Popov" 52 | } 53 | ], 54 | "description": "A PHP parser written in PHP", 55 | "keywords": [ 56 | "parser", 57 | "php" 58 | ], 59 | "support": { 60 | "issues": "https://github.com/nikic/PHP-Parser/issues", 61 | "source": "https://github.com/nikic/PHP-Parser/tree/v4.19.1" 62 | }, 63 | "time": "2024-03-17T08:10:35+00:00" 64 | }, 65 | { 66 | "name": "phpstan/phpstan", 67 | "version": "1.12.0", 68 | "source": { 69 | "type": "git", 70 | "url": "https://github.com/phpstan/phpstan.git", 71 | "reference": "384af967d35b2162f69526c7276acadce534d0e1" 72 | }, 73 | "dist": { 74 | "type": "zip", 75 | "url": "https://api.github.com/repos/phpstan/phpstan/zipball/384af967d35b2162f69526c7276acadce534d0e1", 76 | "reference": "384af967d35b2162f69526c7276acadce534d0e1", 77 | "shasum": "" 78 | }, 79 | "require": { 80 | "php": "^7.2|^8.0" 81 | }, 82 | "conflict": { 83 | "phpstan/phpstan-shim": "*" 84 | }, 85 | "bin": [ 86 | "phpstan", 87 | "phpstan.phar" 88 | ], 89 | "type": "library", 90 | "autoload": { 91 | "files": [ 92 | "bootstrap.php" 93 | ] 94 | }, 95 | "notification-url": "https://packagist.org/downloads/", 96 | "license": [ 97 | "MIT" 98 | ], 99 | "description": "PHPStan - PHP Static Analysis Tool", 100 | "keywords": [ 101 | "dev", 102 | "static analysis" 103 | ], 104 | "support": { 105 | "docs": "https://phpstan.org/user-guide/getting-started", 106 | "forum": "https://github.com/phpstan/phpstan/discussions", 107 | "issues": "https://github.com/phpstan/phpstan/issues", 108 | "security": "https://github.com/phpstan/phpstan/security/policy", 109 | "source": "https://github.com/phpstan/phpstan-src" 110 | }, 111 | "funding": [ 112 | { 113 | "url": "https://github.com/ondrejmirtes", 114 | "type": "github" 115 | }, 116 | { 117 | "url": "https://github.com/phpstan", 118 | "type": "github" 119 | } 120 | ], 121 | "time": "2024-08-27T09:18:05+00:00" 122 | } 123 | ], 124 | "packages-dev": [], 125 | "aliases": [], 126 | "minimum-stability": "stable", 127 | "stability-flags": [], 128 | "prefer-stable": false, 129 | "prefer-lowest": false, 130 | "platform": [], 131 | "platform-dev": [], 132 | "plugin-api-version": "2.6.0" 133 | } 134 | -------------------------------------------------------------------------------- /php/config.2.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | # Replaced with location of user file when used 3 | - ../test/demo/phpstan.neon 4 | 5 | rules: 6 | - PHPStanVSCodeTreeFetcher 7 | 8 | parameters: 9 | # Use a custom cacheDir so that the transformed-args and 10 | # default args don't clear each others' caches. 11 | tmpDir: ../test/cacheDir 12 | 13 | services: 14 | - 15 | class: PHPStanVSCodeTreeFetcherCollector 16 | tags: 17 | - phpstan.collector -------------------------------------------------------------------------------- /php/config.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | # Replaced with location of user file when used 3 | - ../test/demo/phpstan.neon 4 | 5 | rules: 6 | - PHPStanVSCodeTreeFetcher 7 | 8 | parameters: 9 | # Use a custom cacheDir so that the transformed-args and 10 | # default args don't clear each others' caches. 11 | tmpDir: ../test/cacheDir 12 | 13 | services: 14 | - 15 | class: PHPStanVSCodeTreeFetcherCollector 16 | tags: 17 | - phpstan.collector -------------------------------------------------------------------------------- /scripts/get-file-tree.ts: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | //bin/true && exec /usr/bin/env bun --silent --ignore-engines tsx $0 $@ 3 | 4 | import { spawn } from 'child_process'; 5 | import { createReadStream } from 'fs'; 6 | import * as tmp from 'tmp-promise'; 7 | import * as fs from 'fs/promises'; 8 | import * as path from 'path'; 9 | 10 | const ROOT_DIR = path.join(__dirname, '../'); 11 | const ENCODING = { 12 | encoding: 'utf8', 13 | } as const; 14 | 15 | interface Args { 16 | input: string; 17 | output?: string; 18 | silent?: boolean; 19 | runOnly?: boolean; 20 | } 21 | 22 | function getArgs(): Args { 23 | const args: Partial = {}; 24 | for (let i = 2; i < process.argv.length; i++) { 25 | const arg = process.argv[i]; 26 | if (arg === '-i') { 27 | args.input = process.argv[i + 1]; 28 | i++; 29 | } else if (arg === '-o') { 30 | args.output = process.argv[i + 1]; 31 | i++; 32 | } else if (arg === '-r') { 33 | args.runOnly = true; 34 | } else if (arg === '-s') { 35 | args.silent = true; 36 | } else { 37 | args.input = arg; 38 | } 39 | } 40 | 41 | if (!args.input) { 42 | throw new Error('Missing input file, please supply one'); 43 | } 44 | if (!args.input.endsWith('.php')) { 45 | throw new Error('Input file must be a PHP file'); 46 | } 47 | 48 | return args as Args; 49 | } 50 | 51 | interface CommandPrep { 52 | hasNoOutput: boolean; 53 | autoloadFile: string; 54 | dispose: () => Promise; 55 | } 56 | 57 | async function setupCommand(args: Args): Promise { 58 | const hasNoOutput = !args.output; 59 | const tmpDir = await tmp.dir(); 60 | if (hasNoOutput) { 61 | args.output = path.join(tmpDir.path, 'output.json'); 62 | } 63 | const treeFetcherPath = path.join(tmpDir.path, 'TreeFetcher.php'); 64 | const treeFetcherContent = ( 65 | await fs.readFile(path.join(ROOT_DIR, 'php/TreeFetcher.php'), ENCODING) 66 | ).replace('reported.json', args.output!); 67 | await fs.writeFile(treeFetcherPath, treeFetcherContent, ENCODING); 68 | 69 | const autoloadFilePath = path.join(tmpDir.path, 'autoload.php'); 70 | const autoloadFileConent = ` { 77 | await fs.rm(tmpDir.path, { recursive: true }); 78 | }, 79 | }; 80 | } 81 | 82 | function runCommand(prep: CommandPrep, args: Args): Promise { 83 | return new Promise((resolve, reject) => { 84 | const command = [ 85 | 'analyse', 86 | '-c', 87 | path.join(ROOT_DIR, 'php/config.neon'), 88 | '-a', 89 | prep.autoloadFile, 90 | '--debug', 91 | args.input, 92 | '--memory-limit=4G', 93 | ]; 94 | 95 | const proc = spawn( 96 | path.join(ROOT_DIR, 'php/vendor/bin/phpstan'), 97 | command, 98 | { 99 | shell: process.platform === 'win32', 100 | windowsVerbatimArguments: true, 101 | } 102 | ); 103 | if (!args.silent) { 104 | proc.stdout.pipe(process.stdout); 105 | proc.stderr.pipe(process.stderr); 106 | } 107 | 108 | proc.on('error', (e) => { 109 | reject(new Error('Failed to run phpstan: ' + e.message)); 110 | }); 111 | proc.on('exit', () => { 112 | resolve(); 113 | }); 114 | }); 115 | } 116 | 117 | async function main(): Promise { 118 | const args = getArgs(); 119 | const prep = await setupCommand(args); 120 | await runCommand(prep, args); 121 | if (prep.hasNoOutput && !args.runOnly) { 122 | createReadStream(args.output!).pipe(process.stdout); 123 | } 124 | if (!args.silent) { 125 | console.log('Done!'); 126 | } 127 | await prep.dispose(); 128 | } 129 | 130 | void main(); 131 | -------------------------------------------------------------------------------- /server/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanderRonde/phpstan-vscode/298c7b748d6e1c3737248e244390a2e863376d58/server/bun.lockb -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phpstan-vscode-server", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "php-parser": "^3.1.5", 8 | "tmp-promise": "^3.0.3", 9 | "vscode-languageserver": "^8.0.1", 10 | "vscode-languageserver-textdocument": "^1.0.5", 11 | "vscode-uri": "^3.0.3", 12 | "ws": "^8.14.2" 13 | }, 14 | "scripts": { 15 | "build": "esbuild --minify --bundle --outfile=../out/server.js src/server.ts --platform=node --external:vscode", 16 | "build-debug": "esbuild --bundle --outfile=../out/server.js src/server.ts --platform=node --external:vscode --sourcemap=inline" 17 | }, 18 | "devDependencies": { 19 | "@types/ws": "^8.5.9" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /server/src/lib/commands.ts: -------------------------------------------------------------------------------- 1 | import { commandNotification } from './notificationChannels'; 2 | import type { _Connection } from 'vscode-languageserver'; 3 | 4 | export async function executeCommand( 5 | connection: _Connection, 6 | ...args: string[] 7 | ): Promise { 8 | await connection.sendNotification(commandNotification, { 9 | commandName: args[0], 10 | commandArgs: args.slice(1), 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /server/src/lib/configResolver.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigResolveRequestType } from '../../../shared/requestChannels'; 2 | import { configResolveRequest, findFilesRequest } from './requestChannels'; 3 | import type { Disposable } from 'vscode-languageserver'; 4 | import { ParsedConfigFile } from '../../../shared/neon'; 5 | import { getEditorConfiguration } from './editorConfig'; 6 | import type { ClassConfig } from './types'; 7 | import { log, NEON_PREFIX } from './log'; 8 | import { URI } from 'vscode-uri'; 9 | import path from 'path'; 10 | 11 | interface Config { 12 | uri: URI; 13 | file: ParsedConfigFile; 14 | } 15 | 16 | export class ConfigResolver implements Disposable { 17 | private readonly _disposables: Disposable[] = []; 18 | private _configs: Config[][] | undefined; 19 | 20 | public constructor(private readonly _classConfig: ClassConfig) { 21 | this._disposables.push( 22 | this._classConfig.connection.onRequest( 23 | configResolveRequest, 24 | async ( 25 | params 26 | ): Promise => { 27 | return { 28 | uri: 29 | ( 30 | await this.resolveConfigForFile( 31 | URI.parse(params.uri) 32 | ) 33 | )?.uri.toString() ?? null, 34 | }; 35 | } 36 | ) 37 | ); 38 | } 39 | 40 | private async _findConfigs(): Promise { 41 | if (!this._configs) { 42 | const editorConfig = await getEditorConfiguration( 43 | this._classConfig 44 | ); 45 | const configFilePaths = editorConfig.configFile 46 | .split(',') 47 | .map((configFile) => path.basename(configFile.trim())); 48 | const configs: Config[][] = []; 49 | for (const configFilePath of configFilePaths) { 50 | const findFilesResult = 51 | await this._classConfig.connection.sendRequest( 52 | findFilesRequest, 53 | { pattern: `**/${configFilePath}` } 54 | ); 55 | if (findFilesResult.files.length === 0) { 56 | continue; 57 | } 58 | const fileURIs = findFilesResult.files.map((file) => 59 | URI.parse(file) 60 | ); 61 | configs.push( 62 | await Promise.all( 63 | fileURIs.map(async (fileURI) => ({ 64 | uri: fileURI, 65 | file: await ParsedConfigFile.from( 66 | fileURI.fsPath, 67 | (error) => { 68 | log( 69 | NEON_PREFIX, 70 | `Error while parsing .neon file "${fileURI.fsPath}": ${error.message}` 71 | ); 72 | } 73 | ), 74 | })) 75 | ) 76 | ); 77 | } 78 | this._configs = configs; 79 | } 80 | return this._configs; 81 | } 82 | 83 | /** 84 | * When given a file path, orders all configs such that: 85 | * - Configs in the same directory as the file are first 86 | * - This is followed by configs in parent directories, sorted by depth (closest to file first) 87 | * - This is followed by configs going down the root directory, sorted by depth (closest to root first) 88 | */ 89 | private async _findConfigsOrderedForFile( 90 | filePath: URI 91 | ): Promise { 92 | const configs = await this._findConfigs(); 93 | const filePathDir = path.dirname(filePath.fsPath); 94 | 95 | return configs.map((configGroup) => { 96 | // Create path info for each config in group 97 | const configsWithPathInfo = configGroup.map((config) => ({ 98 | config, 99 | configDir: path.dirname(config.uri.fsPath), 100 | // Get relative path from file to config (going up) 101 | relativeToFile: path.relative( 102 | filePathDir, 103 | path.dirname(config.uri.fsPath) 104 | ), 105 | })); 106 | 107 | return configsWithPathInfo 108 | .sort((a, b) => { 109 | // If config is in same dir as file or above (starts with ..), sort by path depth 110 | const aIsAboveOrSame = !a.relativeToFile.startsWith('..'); 111 | const bIsAboveOrSame = !b.relativeToFile.startsWith('..'); 112 | 113 | if (aIsAboveOrSame && !bIsAboveOrSame) { 114 | return -1; 115 | } 116 | if (!aIsAboveOrSame && bIsAboveOrSame) { 117 | return 1; 118 | } 119 | 120 | // Both above/same or both below 121 | if (aIsAboveOrSame) { 122 | // Sort by path depth (shorter = higher up = first) 123 | return ( 124 | a.relativeToFile.length - b.relativeToFile.length 125 | ); 126 | } else { 127 | // Sort by path depth (shorter = closer = first) 128 | return ( 129 | a.relativeToFile.length - b.relativeToFile.length 130 | ); 131 | } 132 | }) 133 | .map((info) => info.config); 134 | }); 135 | } 136 | 137 | private async getSingleConfig(): Promise { 138 | const configs = await this._findConfigs(); 139 | if (configs.length === 0) { 140 | return null; 141 | } 142 | if (configs[0].length !== 1) { 143 | return null; 144 | } 145 | return configs[0][0]; 146 | } 147 | 148 | private async resolveConfigForFile(filePath: URI): Promise { 149 | const configGroups = await this._findConfigsOrderedForFile(filePath); 150 | for (const configGroup of configGroups) { 151 | for (const config of configGroup) { 152 | if (config.file.isInPaths(filePath.fsPath)) { 153 | return config; 154 | } 155 | } 156 | } 157 | return null; 158 | } 159 | 160 | public async resolveConfig(filePath: URI | null): Promise { 161 | if (filePath) { 162 | return this.resolveConfigForFile(filePath); 163 | } 164 | return this.getSingleConfig(); 165 | } 166 | 167 | /** 168 | * Best-effort tries to get all configs such that their 169 | * included paths don't overlap. 170 | */ 171 | public async getAllConfigs(): Promise { 172 | const coveredPaths = new Set(); 173 | 174 | const allConfigs: Config[] = []; 175 | const configGroups = await this._findConfigs(); 176 | for (const configGroup of configGroups) { 177 | for (const config of configGroup) { 178 | for (const relativeIncludedPath of config.file.paths) { 179 | const absoluteIncludedPath = path.join( 180 | path.dirname(config.uri.fsPath), 181 | relativeIncludedPath 182 | ); 183 | if (coveredPaths.has(absoluteIncludedPath)) { 184 | continue; 185 | } 186 | coveredPaths.add(absoluteIncludedPath); 187 | allConfigs.push(config); 188 | } 189 | } 190 | } 191 | return allConfigs; 192 | } 193 | 194 | public clearCache(): void { 195 | this._configs = undefined; 196 | } 197 | 198 | public dispose(): void { 199 | this._disposables.forEach((d) => d.dispose()); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /server/src/lib/debug.ts: -------------------------------------------------------------------------------- 1 | import { debugNotification } from './notificationChannels'; 2 | import type { _Connection } from 'vscode-languageserver'; 3 | 4 | export function debug( 5 | connection: _Connection, 6 | type: string, 7 | ...data: unknown[] 8 | ): void { 9 | void connection.sendNotification(debugNotification, { 10 | debugData: { 11 | type, 12 | data, 13 | }, 14 | }); 15 | } 16 | 17 | export * from '../../../shared/debug'; 18 | -------------------------------------------------------------------------------- /server/src/lib/editorConfig.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ConfigSettingsWithoutPrefix, 3 | DockerConfigSettings, 4 | } from '../../../shared/config'; 5 | import { replaceHomeDir, replaceVariables } from '../../../shared/variables'; 6 | import { fromEntries } from '../../../shared/util'; 7 | import type { ClassConfig } from './types'; 8 | 9 | export async function getEditorConfiguration( 10 | classConfig: Pick< 11 | ClassConfig, 12 | 'connection' | 'workspaceFolders' | 'editorConfigOverride' 13 | > 14 | ): Promise> { 15 | const workspaceFolders = await classConfig.workspaceFolders.get(); 16 | const scope = workspaceFolders?.default?.toString(); 17 | 18 | const editorConfig = { 19 | ...((await classConfig.connection.workspace.getConfiguration({ 20 | scopeUri: scope, 21 | section: 'phpstan', 22 | })) as ConfigSettingsWithoutPrefix), 23 | ...(await classConfig.editorConfigOverride.get()), 24 | }; 25 | 26 | let tmpDir = editorConfig.tmpDir; 27 | if (!tmpDir) { 28 | tmpDir = editorConfig.proTmpDir || editorConfig.tmpDir; 29 | } 30 | return { 31 | ...editorConfig, 32 | binPath: replaceHomeDir( 33 | replaceVariables(editorConfig.binPath, workspaceFolders) 34 | ), 35 | binCommand: editorConfig.binCommand.map((part) => 36 | replaceHomeDir(replaceVariables(part, workspaceFolders)) 37 | ), 38 | configFile: replaceHomeDir( 39 | replaceVariables(editorConfig.configFile, workspaceFolders) 40 | ), 41 | paths: fromEntries( 42 | Object.entries(editorConfig.paths).map(([key, value]) => [ 43 | replaceVariables(key, workspaceFolders), 44 | replaceVariables(value, workspaceFolders), 45 | ]) 46 | ), 47 | tmpDir: replaceHomeDir(replaceVariables(tmpDir, workspaceFolders)), 48 | rootDir: replaceHomeDir( 49 | replaceVariables(editorConfig.rootDir, workspaceFolders) 50 | ), 51 | options: editorConfig.options.map((option) => 52 | replaceVariables(option, workspaceFolders) 53 | ), 54 | ignoreErrors: editorConfig.ignoreErrors.map((error) => { 55 | if (error instanceof RegExp) { 56 | return new RegExp( 57 | replaceVariables(error.source, workspaceFolders) 58 | ); 59 | } 60 | return replaceVariables(error, workspaceFolders); 61 | }), 62 | showTypeOnHover: 63 | editorConfig.enableLanguageServer || 64 | editorConfig.showTypeOnHover || 65 | false, 66 | }; 67 | } 68 | 69 | export async function getDockerEnvironment( 70 | classConfig: Pick 71 | ): Promise | null> { 72 | const workspaceFolders = await classConfig.workspaceFolders.get(); 73 | const scope = workspaceFolders?.default?.toString(); 74 | const editorConfig = { 75 | ...((await classConfig.connection.workspace.getConfiguration({ 76 | scopeUri: scope, 77 | section: 'docker', 78 | })) as DockerConfigSettings), 79 | }; 80 | return editorConfig['docker.environment']; 81 | } 82 | -------------------------------------------------------------------------------- /server/src/lib/errorUtil.ts: -------------------------------------------------------------------------------- 1 | import { commandNotification } from './notificationChannels'; 2 | import { Commands } from '../../../shared/commands/defs'; 3 | import type { _Connection } from 'vscode-languageserver'; 4 | import { ERROR_PREFIX, log } from './log'; 5 | 6 | const shownWarnings: Set = new Set(); 7 | 8 | export function showErrorOnce( 9 | connection: _Connection, 10 | message: string, 11 | ...extra: string[] 12 | ): void { 13 | log(ERROR_PREFIX, message, ...extra); 14 | if (shownWarnings.has(message)) { 15 | return; 16 | } 17 | showError(connection, message, [ 18 | { 19 | title: 'Launch setup', 20 | callback: () => { 21 | void connection.sendNotification(commandNotification, { 22 | commandName: Commands.LAUNCH_SETUP, 23 | commandArgs: [], 24 | }); 25 | }, 26 | }, 27 | ]); 28 | shownWarnings.add(message); 29 | } 30 | 31 | interface ErrorOption { 32 | title: string; 33 | callback: () => void; 34 | } 35 | 36 | export function showError( 37 | connection: _Connection, 38 | message: string, 39 | options: ErrorOption[] = [] 40 | ): void { 41 | void connection.window 42 | .showErrorMessage(message, ...options.map(({ title }) => ({ title }))) 43 | .then((choice) => { 44 | if (!options || !choice) { 45 | return; 46 | } 47 | 48 | const match = options.find((o) => o.title === choice.title); 49 | void match?.callback(); 50 | }); 51 | } 52 | -------------------------------------------------------------------------------- /server/src/lib/log.ts: -------------------------------------------------------------------------------- 1 | import type { PHPStanCheck } from './phpstan/check'; 2 | 3 | export function log(prefix: LogPrefix, ...data: (string | number)[]): void { 4 | data = [prefix, ...data]; 5 | console.log([`[${new Date().toLocaleString()}]`, ...data].join(' ')); 6 | } 7 | 8 | export type LogPrefix = string & { 9 | __isPrefix: true; 10 | }; 11 | 12 | export function checkPrefix(check: PHPStanCheck): LogPrefix { 13 | return `[check:${check.id}]` as LogPrefix; 14 | } 15 | 16 | export const MANAGER_PREFIX = '[fixer-manager]' as LogPrefix; 17 | export const WATCHER_PREFIX = '[file-watcher]' as LogPrefix; 18 | export const ERROR_PREFIX = '[error]' as LogPrefix; 19 | export const HOVER_PROVIDER_PREFIX = '[hover-provider]' as LogPrefix; 20 | export const SERVER_PREFIX = '[server]' as LogPrefix; 21 | export const PRO_PREFIX = '[pro]' as LogPrefix; 22 | export const NEON_PREFIX = '[neon]' as LogPrefix; 23 | -------------------------------------------------------------------------------- /server/src/lib/notificationChannels.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CommandNotificationType, 3 | DebugNotificationType, 4 | ErrorNotificationType, 5 | PHPStanProNotificationType, 6 | ProcessNotificationType, 7 | StatusBarNotificationType, 8 | WatcherNotificationType, 9 | } from '../../../shared/notificationChannels'; 10 | import { NotificationChannel } from '../../../shared/notificationChannels'; 11 | import { NotificationType } from 'vscode-languageserver'; 12 | 13 | export const watcherNotification = 14 | new NotificationType(NotificationChannel.WATCHER); 15 | 16 | export const commandNotification = 17 | new NotificationType(NotificationChannel.COMMAND); 18 | 19 | export const statusBarNotification = 20 | new NotificationType( 21 | NotificationChannel.STATUS_BAR 22 | ); 23 | 24 | export const errorNotification = new NotificationType( 25 | NotificationChannel.ERROR 26 | ); 27 | 28 | export const processNotification = 29 | new NotificationType(NotificationChannel.SPAWNER); 30 | 31 | export const phpstanProNotification = 32 | new NotificationType( 33 | NotificationChannel.PHPSTAN_PRO 34 | ); 35 | 36 | export const debugNotification = new NotificationType( 37 | NotificationChannel.DEBUG 38 | ); 39 | -------------------------------------------------------------------------------- /server/src/lib/phpstan/pro/pro.ts: -------------------------------------------------------------------------------- 1 | import { pathExists, tryReadJSON, wait } from '../../../../../shared/util'; 2 | import { ConfigurationManager } from '../../checkConfigManager'; 3 | import { SPAWN_ARGS } from '../../../../../shared/constants'; 4 | import { getEditorConfiguration } from '../../editorConfig'; 5 | import type { ConfigResolver } from '../../configResolver'; 6 | import { PHPStanProErrorManager } from './proErrorManager'; 7 | import type { Disposable } from 'vscode-languageserver'; 8 | import type { ClassConfig } from '../../types'; 9 | import { ReturnResult } from '../../result'; 10 | import { PRO_PREFIX, log } from '../../log'; 11 | import { Process } from '../../process'; 12 | import * as path from 'path'; 13 | 14 | export async function launchPro( 15 | classConfig: ClassConfig, 16 | configResolver: ConfigResolver, 17 | onProgress?: (progress: { 18 | done: number; 19 | total: number; 20 | percentage: number; 21 | }) => void 22 | ): Promise> { 23 | const settings = await getEditorConfiguration(classConfig); 24 | const tmpPath = settings.tmpDir; 25 | 26 | const launchConfig = await ConfigurationManager.collectConfiguration( 27 | classConfig, 28 | configResolver, 29 | 'analyse', 30 | null, 31 | null 32 | ); 33 | if (!launchConfig) { 34 | return ReturnResult.error('Failed to find launch configuration'); 35 | } 36 | 37 | const [binStr, ...args] = await ConfigurationManager.getArgs( 38 | classConfig, 39 | launchConfig, 40 | false 41 | ); 42 | const env = { ...process.env }; 43 | const configuration: Record = { 44 | binStr, 45 | args: [...args, '--watch'], 46 | }; 47 | if (tmpPath) { 48 | env.TMPDIR = tmpPath; 49 | configuration['tmpDir'] = tmpPath; 50 | } 51 | log( 52 | PRO_PREFIX, 53 | 'Spawning PHPStan Pro with the following configuration: ', 54 | JSON.stringify(configuration) 55 | ); 56 | const proc = await Process.spawnWithRobustTimeout( 57 | classConfig, 58 | PRO_PREFIX, 59 | binStr, 60 | [...args, '--watch'], 61 | 0, 62 | { 63 | ...SPAWN_ARGS, 64 | cwd: launchConfig.cwd, 65 | encoding: 'utf-8', 66 | env: env, 67 | } 68 | ); 69 | 70 | return new Promise>((resolve) => { 71 | let stderr: string = ''; 72 | proc.stdout?.on('data', (chunk: string | Buffer) => { 73 | const line = chunk.toString(); 74 | const progressMatch = [ 75 | ...line.matchAll(/(\d+)\/(\d+)\s+\[.*?\]\s+(\d+)%/g), 76 | ]; 77 | log(PRO_PREFIX, 'PHPStan Pro: ' + line); 78 | if (onProgress && progressMatch.length) { 79 | const [, done, total, percentage] = 80 | progressMatch[progressMatch.length - 1]; 81 | onProgress({ 82 | done: parseInt(done, 10), 83 | total: parseInt(total, 10), 84 | percentage: parseInt(percentage, 10), 85 | }); 86 | return; 87 | } 88 | if (line.includes('Open your web browser at:')) { 89 | // We got some text, the process is running. 90 | // Wait a slight while for PHPStan to move to the pro part 91 | void wait(100).then(async () => { 92 | // Check if config folder exists 93 | const configDirPath = path.join(tmpPath, 'phpstan-fixer'); 94 | const folderExists = await pathExists(configDirPath); 95 | 96 | if (!folderExists) { 97 | resolve( 98 | ReturnResult.error( 99 | `Failed to launch PHPStan Pro (tmp folder does not exist: "${configDirPath}"). Does the \`phpstan.tmpDir\` setting match the tmpDir in your config file?` 100 | ) 101 | ); 102 | } else { 103 | resolve( 104 | ReturnResult.success( 105 | new PHPStanProProcess( 106 | classConfig, 107 | configDirPath 108 | ) 109 | ) 110 | ); 111 | } 112 | }); 113 | } 114 | }); 115 | proc.stderr?.on('data', (chunk: string | Buffer) => { 116 | stderr += chunk.toString(); 117 | }); 118 | proc.onError((error) => { 119 | resolve( 120 | ReturnResult.error( 121 | `Failed to launch PHPStan Pro: ${error.message} - ${stderr}` 122 | ) 123 | ); 124 | }); 125 | proc.onExit((code) => { 126 | resolve( 127 | ReturnResult.error( 128 | `PHPStan Pro exited with code ${code ?? '?'}: ${stderr}` 129 | ) 130 | ); 131 | }); 132 | }); 133 | } 134 | 135 | class PHPStanProProcess implements Disposable { 136 | private _disposables: Disposable[] = []; 137 | 138 | public constructor( 139 | classConfig: ClassConfig, 140 | private readonly _configDirPath: string 141 | ) { 142 | void this.getPort().then((port) => { 143 | if (!port) { 144 | return; 145 | } 146 | this._disposables.push( 147 | new PHPStanProErrorManager(classConfig, port) 148 | ); 149 | }); 150 | } 151 | 152 | public isLoggedIn(): Promise { 153 | return pathExists(path.join(this._configDirPath, 'login_payload.jwt')); 154 | } 155 | 156 | public async getPort(): Promise { 157 | const portFile = await tryReadJSON<{ 158 | port: number; 159 | }>(path.join(this._configDirPath, 'port.json')); 160 | return portFile?.port ?? null; 161 | } 162 | 163 | public dispose(): void { 164 | for (const disposable of this._disposables) { 165 | disposable.dispose(); 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /server/src/lib/phpstan/pro/proErrorManager.ts: -------------------------------------------------------------------------------- 1 | import { OperationStatus } from '../../../../../shared/statusBar'; 2 | import { errorNotification } from '../../notificationChannels'; 3 | import { getEditorConfiguration } from '../../editorConfig'; 4 | import { getPathMapper } from '../../../../../shared/util'; 5 | import type { StatusBarOperation } from '../../statusBar'; 6 | import type { Disposable } from 'vscode-languageserver'; 7 | import type { ClassConfig } from '../../types'; 8 | import type { ReportedErrors } from '../check'; 9 | import { log, PRO_PREFIX } from '../../log'; 10 | import { URI } from 'vscode-uri'; 11 | import * as http from 'http'; 12 | import * as ws from 'ws'; 13 | 14 | export class PHPStanProErrorManager implements Disposable { 15 | private _wsClient: ws.WebSocket | null = null; 16 | private _pathMapper: 17 | | ((filePath: string, inverse?: boolean) => string) 18 | | undefined = undefined; 19 | 20 | public constructor( 21 | private readonly _classConfig: ClassConfig, 22 | private readonly _port: number 23 | ) { 24 | this._connect(); 25 | } 26 | 27 | private async _progressUpdate( 28 | operation: StatusBarOperation | null, 29 | progress: ProProgress, 30 | onDone: () => Promise 31 | ): Promise { 32 | if (!progress.inProgress) { 33 | return onDone(); 34 | } 35 | 36 | if (operation) { 37 | const progressPercentage = Math.round( 38 | (progress.done / progress.total) * 100 39 | ); 40 | await operation.progress( 41 | { 42 | done: progress.done, 43 | total: progress.total, 44 | percentage: progressPercentage, 45 | }, 46 | `PHPStan checking project - ${progress.done}/${progress.total} (${progressPercentage}%)` 47 | ); 48 | } 49 | } 50 | 51 | private _activeRequest: 52 | | { 53 | state: 'pending'; 54 | queueNextRequest: boolean; 55 | } 56 | | { 57 | state: 'none'; 58 | } = { state: 'none' }; 59 | private async _queueProgressUpdate( 60 | operation: StatusBarOperation | null, 61 | onDone: () => Promise 62 | ): Promise { 63 | if (this._activeRequest.state === 'pending') { 64 | this._activeRequest.queueNextRequest = true; 65 | return; 66 | } 67 | this._activeRequest = { 68 | state: 'pending', 69 | queueNextRequest: false, 70 | }; 71 | const progress = await this._collectProgress(); 72 | if (progress) { 73 | await this._progressUpdate(operation, progress, onDone); 74 | } 75 | 76 | const nextRequest = this._activeRequest.queueNextRequest; 77 | this._activeRequest = { 78 | state: 'none', 79 | }; 80 | if (nextRequest) { 81 | void this._queueProgressUpdate(operation, onDone); 82 | } 83 | } 84 | 85 | private _connect(): void { 86 | const url = `ws://127.0.0.1:${this._port}/websocket`; 87 | this._wsClient = new ws.WebSocket(url); 88 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 89 | this._wsClient.on('error', async () => { 90 | const choice = 91 | await this._classConfig.connection.window.showErrorMessage( 92 | `PHPStan Pro failed to make websocket connection to: ${url}`, 93 | { 94 | title: 'Retry', 95 | } 96 | ); 97 | if (choice?.title === 'Retry') { 98 | this._connect(); 99 | } 100 | }); 101 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 102 | this._wsClient.on('close', async () => { 103 | const choice = 104 | await this._classConfig.connection.window.showErrorMessage( 105 | `PHPStan Pro disconnected from websocket URL: ${url}`, 106 | { 107 | title: 'Retry', 108 | } 109 | ); 110 | if (choice?.title === 'Retry') { 111 | this._connect(); 112 | } 113 | }); 114 | 115 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 116 | this._wsClient.on('open', async () => { 117 | await this._classConfig.hooks.provider.onCheckDone(); 118 | 119 | void this._applyErrors(); 120 | }); 121 | 122 | let checkOperation: StatusBarOperation | null = null; 123 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 124 | this._wsClient.on('message', async (data: Buffer) => { 125 | const msg = JSON.parse(data.toString()) as WSMessage; 126 | // ProgressUpdate requests are very spammy, let's not log every time 127 | if (msg.action !== 'progressUpdate') { 128 | log(PRO_PREFIX, `Received message of type: ${msg.action}`); 129 | } 130 | 131 | const onAnalysisDone = async (): Promise => { 132 | if (checkOperation) { 133 | await this._classConfig.hooks.provider.onCheckDone(); 134 | await checkOperation.finish(OperationStatus.SUCCESS); 135 | checkOperation = null; 136 | } 137 | await this._applyErrors(); 138 | }; 139 | if ( 140 | msg.action === 'analysisStart' || 141 | msg.action === 'changedFile' 142 | ) { 143 | if (checkOperation) { 144 | // Check already exists, finish that one 145 | await onAnalysisDone(); 146 | } 147 | 148 | checkOperation = this._classConfig.statusBar.createOperation(); 149 | await Promise.all([ 150 | checkOperation.start('PHPStan Pro Checking...'), 151 | this._classConfig.connection.sendNotification( 152 | errorNotification, 153 | { 154 | diagnostics: { 155 | fileSpecificErrors: {}, 156 | notFileSpecificErrors: [], 157 | }, 158 | } 159 | ), 160 | ]); 161 | } else if (msg.action === 'analysisEnd') { 162 | await onAnalysisDone(); 163 | } else if (msg.action === 'progressUpdate') { 164 | await this._queueProgressUpdate(checkOperation, onAnalysisDone); 165 | } 166 | }); 167 | } 168 | 169 | private _collectProgress(): Promise { 170 | return this._collectData('progress'); 171 | } 172 | 173 | private _collectErrors(): Promise { 174 | return this._collectData('errors'); 175 | } 176 | 177 | private _collectData(path: string): Promise { 178 | return new Promise((resolve) => { 179 | const req = http.request(`http://127.0.0.1:${this._port}/${path}`); 180 | req.on('response', (res) => { 181 | let data = ''; 182 | res.on('data', (chunk) => { 183 | data += chunk; 184 | }); 185 | res.on('end', () => { 186 | const errors = JSON.parse(data) as T; 187 | resolve(errors); 188 | }); 189 | }); 190 | req.end(); 191 | }); 192 | } 193 | 194 | private async _applyErrors(): Promise { 195 | const errors = await this._collectErrors(); 196 | log(PRO_PREFIX, `Found errors: ${JSON.stringify(errors)}`); 197 | if (!errors) { 198 | // Already cleared, don't apply anything 199 | return; 200 | } 201 | 202 | this._pathMapper ??= getPathMapper( 203 | (await getEditorConfiguration(this._classConfig)).paths, 204 | (await this._classConfig.workspaceFolders.get())?.default?.fsPath 205 | ); 206 | const fileSpecificErrors: ReportedErrors['fileSpecificErrors'] = {}; 207 | for (const fileError of errors.fileSpecificErrors) { 208 | const uri = URI.from({ 209 | scheme: 'file', 210 | path: this._pathMapper(fileError.file, true), 211 | }).toString(); 212 | fileSpecificErrors[uri] ??= []; 213 | fileSpecificErrors[uri].push({ 214 | message: fileError.message, 215 | lineNumber: fileError.line, 216 | ignorable: fileError.ignorable, 217 | identifier: fileError.identifier ?? null, 218 | tip: fileError.tip ?? null, 219 | }); 220 | } 221 | void this._classConfig.connection.sendNotification(errorNotification, { 222 | diagnostics: { 223 | fileSpecificErrors: fileSpecificErrors, 224 | notFileSpecificErrors: errors.notFileSpecificErrors, 225 | }, 226 | }); 227 | } 228 | 229 | public dispose(): void { 230 | this._wsClient?.close(); 231 | } 232 | } 233 | 234 | interface ReportedError { 235 | contextLines: { 236 | lines: string[]; 237 | startLine: number; 238 | }; 239 | // Not sure what the type here is but we don't need it 240 | definiteFixerSuggestion: unknown | null; 241 | file: string; 242 | id: string; 243 | line: number | null; 244 | message: string; 245 | ignorable: boolean; 246 | identifier: string | null; 247 | tip: string | null; 248 | } 249 | 250 | interface ProReportedErrors { 251 | fileSpecificErrors: ReportedError[]; 252 | notFileSpecificErrors: string[]; 253 | } 254 | 255 | interface ProProgress { 256 | done: number; 257 | total: number; 258 | inProgress: boolean; 259 | } 260 | 261 | type WSMessage = 262 | | { 263 | action: 'progressUpdate'; 264 | data: { id: string }; 265 | } 266 | | { 267 | action: 'analysisStart' | 'changedFile'; 268 | } 269 | | { 270 | action: 'analysisEnd'; 271 | data: { 272 | filesCount: number; 273 | }; 274 | }; 275 | -------------------------------------------------------------------------------- /server/src/lib/requestChannels.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ConfigResolveRequestType, 3 | FindFilesRequestType, 4 | InitRequestType, 5 | TestRunRequestType, 6 | } from '../../../shared/requestChannels'; 7 | import { RequestChannel } from '../../../shared/requestChannels'; 8 | import { RequestType } from 'vscode-languageserver'; 9 | 10 | export const initRequest = new RequestType< 11 | InitRequestType['request'], 12 | InitRequestType['response'], 13 | InitRequestType['error'] 14 | >(RequestChannel.INIT); 15 | 16 | export const testRunRequest = new RequestType< 17 | TestRunRequestType['request'], 18 | TestRunRequestType['response'], 19 | TestRunRequestType['error'] 20 | >(RequestChannel.TEST_RUN); 21 | 22 | export const configResolveRequest = new RequestType< 23 | ConfigResolveRequestType['request'], 24 | ConfigResolveRequestType['response'], 25 | ConfigResolveRequestType['error'] 26 | >(RequestChannel.CONFIG_RESOLVE); 27 | 28 | export const findFilesRequest = new RequestType< 29 | FindFilesRequestType['request'], 30 | FindFilesRequestType['response'], 31 | FindFilesRequestType['error'] 32 | >(RequestChannel.FIND_FILES); 33 | -------------------------------------------------------------------------------- /server/src/lib/result.ts: -------------------------------------------------------------------------------- 1 | import { OperationStatus } from '../../../shared/statusBar'; 2 | 3 | export class ReturnResult { 4 | protected constructor( 5 | public status: OperationStatus, 6 | public value: R | null, 7 | public error: E | null = null 8 | ) {} 9 | 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | public static success(result: R): ReturnResult { 12 | return new ReturnResult(OperationStatus.SUCCESS, result); 13 | } 14 | 15 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 16 | public static killed(): ReturnResult { 17 | return new ReturnResult(OperationStatus.KILLED, null); 18 | } 19 | 20 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 21 | public static canceled(): ReturnResult { 22 | return new ReturnResult(OperationStatus.CANCELLED, null); 23 | } 24 | 25 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 26 | public static error(error?: E): ReturnResult { 27 | return new ReturnResult(OperationStatus.ERROR, null, error); 28 | } 29 | 30 | public success(): this is SuccessReturnResult { 31 | return this.status === OperationStatus.SUCCESS; 32 | } 33 | 34 | public chain(operation: (data: R) => N): ReturnResult { 35 | if (!this.success()) { 36 | return this as unknown as ReturnResult; 37 | } 38 | return ReturnResult.success(operation(this.value)) as ReturnResult; 39 | } 40 | 41 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 42 | public cast(): ReturnResult { 43 | return this; 44 | } 45 | } 46 | 47 | class SuccessReturnResult extends ReturnResult { 48 | protected constructor( 49 | public override status: OperationStatus.SUCCESS, 50 | public override value: R 51 | ) { 52 | super(status, value); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /server/src/lib/statusBar.ts: -------------------------------------------------------------------------------- 1 | import type { StatusBarProgress } from '../../../shared/notificationChannels'; 2 | import type { OperationStatus } from '../../../shared/statusBar'; 3 | import { statusBarNotification } from './notificationChannels'; 4 | import type { _Connection } from 'vscode-languageserver'; 5 | 6 | export class StatusBar { 7 | private _lastOperationId: number = 0; 8 | 9 | public constructor(private readonly _connection: _Connection) {} 10 | 11 | public createOperation(): StatusBarOperation { 12 | const id = this._lastOperationId++; 13 | return { 14 | start: async (tooltip: string) => { 15 | await this._connection.sendNotification(statusBarNotification, { 16 | opId: id, 17 | type: 'new', 18 | tooltip, 19 | }); 20 | }, 21 | progress: async (progress: StatusBarProgress, tooltip: string) => { 22 | await this._connection.sendNotification(statusBarNotification, { 23 | progress: progress, 24 | opId: id, 25 | type: 'progress', 26 | tooltip, 27 | }); 28 | }, 29 | finish: async (result: OperationStatus) => { 30 | await this._connection.sendNotification(statusBarNotification, { 31 | opId: id, 32 | result, 33 | type: 'done', 34 | }); 35 | }, 36 | }; 37 | } 38 | } 39 | 40 | export interface StatusBarOperation { 41 | start: (tooltip: string) => Promise; 42 | progress: (progress: StatusBarProgress, tooltip: string) => Promise; 43 | finish: (result: OperationStatus) => Promise; 44 | } 45 | -------------------------------------------------------------------------------- /server/src/lib/test.ts: -------------------------------------------------------------------------------- 1 | import type { Disposable, _Connection } from 'vscode-languageserver'; 2 | import { PHPStanCheckManager } from './phpstan/checkManager'; 3 | import { OperationStatus } from '../../../shared/statusBar'; 4 | import { assertUnreachable } from '../../../shared/util'; 5 | import type { DocumentManager } from './documentManager'; 6 | import type { ConfigResolver } from './configResolver'; 7 | import { testRunRequest } from './requestChannels'; 8 | import { getVersion } from '../start/getVersion'; 9 | import type { ClassConfig } from './types'; 10 | 11 | export function listenTest( 12 | connection: _Connection, 13 | classConfig: ClassConfig, 14 | documentManager: DocumentManager, 15 | configResolver: ConfigResolver, 16 | checkManager: PHPStanCheckManager | undefined 17 | ): Disposable { 18 | return connection.onRequest( 19 | testRunRequest, 20 | async ( 21 | params 22 | ): Promise[1]> => { 23 | classConfig.editorConfigOverride.set(params); 24 | try { 25 | if (params.dryRun) { 26 | return await getVersion(classConfig); 27 | } else { 28 | checkManager ??= new PHPStanCheckManager( 29 | classConfig, 30 | configResolver, 31 | () => documentManager 32 | ); 33 | let error: string | undefined = undefined; 34 | const status = await checkManager.check( 35 | params.file, 36 | null, 37 | 'test', 38 | (_error) => { 39 | error = _error; 40 | } 41 | ); 42 | if (status === OperationStatus.SUCCESS) { 43 | return { success: true }; 44 | } else if (status === OperationStatus.CANCELLED) { 45 | return { 46 | success: false, 47 | error: 'Operation was cancelled, try again', 48 | }; 49 | } else if (status === OperationStatus.KILLED) { 50 | return { 51 | success: false, 52 | error: 'Operation was killed, try again', 53 | }; 54 | } else if (status === OperationStatus.ERROR) { 55 | return { 56 | success: false, 57 | error: error ?? 'Unknown error', 58 | }; 59 | } else { 60 | assertUnreachable(status); 61 | } 62 | } 63 | } finally { 64 | classConfig.editorConfigOverride.set({}); 65 | } 66 | } 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /server/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigSettingsWithoutPrefix } from '../../../shared/config'; 2 | import type { ProviderCheckHooks } from '../providers/providerUtil'; 3 | import type { PHPStanVersion } from '../start/getVersion'; 4 | import type { _Connection } from 'vscode-languageserver'; 5 | import type { StatusBar } from './statusBar'; 6 | import type { URI } from 'vscode-uri'; 7 | 8 | export interface ClassConfig { 9 | statusBar: StatusBar; 10 | connection: _Connection; 11 | workspaceFolders: PromisedValue; 12 | hooks: { 13 | provider: ProviderCheckHooks; 14 | }; 15 | version: PromisedValue; 16 | editorConfigOverride: ResolvedPromisedValue< 17 | Partial 18 | >; 19 | } 20 | 21 | export type WorkspaceFolders = { 22 | byName: { 23 | [name: string]: URI | undefined; 24 | }; 25 | getForPath: (path: string) => URI | undefined; 26 | default?: URI; 27 | }; 28 | 29 | export class PromisedValue { 30 | private _resolve!: (value: V) => void; 31 | private readonly _promise: Promise; 32 | private _wasSet: boolean = false; 33 | 34 | public constructor() { 35 | this._promise = new Promise((resolve) => { 36 | this._resolve = resolve; 37 | }); 38 | } 39 | 40 | public set(value: V): void { 41 | this._resolve(value); 42 | this._wasSet = true; 43 | } 44 | 45 | public get(): Promise { 46 | return this._promise; 47 | } 48 | 49 | public isSet(): boolean { 50 | return this._wasSet; 51 | } 52 | } 53 | 54 | export class ResolvedPromisedValue extends PromisedValue { 55 | public constructor(value: V) { 56 | super(); 57 | if (value) { 58 | this.set(value); 59 | } 60 | } 61 | } 62 | 63 | export interface AsyncDisposable { 64 | dispose: () => Promise; 65 | } 66 | -------------------------------------------------------------------------------- /server/src/lib/watcher.ts: -------------------------------------------------------------------------------- 1 | import type { PHPStanCheckManager } from './phpstan/checkManager'; 2 | import type { AsyncDisposable, ClassConfig } from './types'; 3 | import type { DocumentManager } from './documentManager'; 4 | import { type Disposable } from 'vscode'; 5 | 6 | // Temporarily(?) disabled since it heavily impacts the CPU 7 | export class Watcher implements AsyncDisposable { 8 | private readonly _disposables: Set = new Set(); 9 | // private _filesWatcher: FilesWatcher | null = null; 10 | // private readonly _checkDebouncer = new CheckDebouncer(); 11 | public documentManager: DocumentManager | null = null; 12 | 13 | public constructor( 14 | private readonly _classConfig: ClassConfig, 15 | private readonly _checkManager: PHPStanCheckManager 16 | ) { 17 | // void this._init(); 18 | } 19 | 20 | // private async _init(): Promise { 21 | // const version = await this._classConfig.version.get(); 22 | // if (!version) { 23 | // return; 24 | // } 25 | 26 | // if (!(version.major > 1 || version.minor >= 12)) { 27 | // return; 28 | // } 29 | 30 | // // Periodically refresh in case files are added 31 | // const interval = setInterval( 32 | // () => { 33 | // void this.onConfigChange(); 34 | // }, 35 | // 1000 * 60 * 5 36 | // ); 37 | // setTimeout(() => { 38 | // // Don't immediately watch due to the relatively high overhead of watching 39 | // void this.onConfigChange(); 40 | // }, 1000 * 10); 41 | // this._disposables.add({ 42 | // dispose: () => { 43 | // clearInterval(interval); 44 | // }, 45 | // }); 46 | // } 47 | 48 | // private async _getFiles(): Promise { 49 | // // Gather to-watch files 50 | // const diagnosis = new PHPStanDiagnose(this._classConfig); 51 | // const runningCheck = withTimeout< 52 | // ReturnResult, 53 | // Promise> 54 | // >({ 55 | // resolve: diagnosis.diagnose(() => {}), 56 | // dispose: new Promise((resolve) => 57 | // diagnosis.disposables.push({ 58 | // dispose: () => Promise.resolve(resolve()), 59 | // }) 60 | // ), 61 | // timeout: 1000 * 60, 62 | // onKill: async () => { 63 | // await diagnosis.dispose(); 64 | // return ReturnResult.killed(); 65 | // }, 66 | // }); 67 | // this._disposables.add(runningCheck); 68 | // diagnosis.disposables.push(runningCheck); 69 | 70 | // const result = await runningCheck.promise; 71 | // this._disposables.delete(runningCheck); 72 | // if (!result.success()) { 73 | // return []; 74 | // } 75 | 76 | // const lines = result.value.split('\n'); 77 | // for (const line of lines) { 78 | // const match = /PHPStanVSCodeDiagnoser:(.*)/.exec(line); 79 | // if (match) { 80 | // return JSON.parse(match[1]) as string[]; 81 | // } 82 | // } 83 | // return []; 84 | // } 85 | 86 | public async onConfigChange(): Promise {} 87 | 88 | // public async onConfigChange(): Promise { 89 | // const editorConfig = await getEditorConfiguration(this._classConfig); 90 | // if ( 91 | // editorConfig.singleFileMode || 92 | // !editorConfig.enabled || 93 | // !this.documentManager 94 | // ) { 95 | // await this._filesWatcher?.dispose(); 96 | // this._filesWatcher = null; 97 | // return; 98 | // } 99 | 100 | // const files = await this._checkDebouncer.debounceWithKey( 101 | // 'get-files', 102 | // () => { 103 | // return this._getFiles(); 104 | // } 105 | // ); 106 | 107 | // if (this._filesWatcher) { 108 | // const watchedFiles = this._filesWatcher.files; 109 | // const watchedFilePaths = Object.values(watchedFiles).flat(); 110 | // const newFilesSet = new Set(files); 111 | 112 | // if ( 113 | // watchedFilePaths.length === files.length && 114 | // watchedFilePaths.every((file) => newFilesSet.has(file)) 115 | // ) { 116 | // // If files didn't change, don't re-watch 117 | // return; 118 | // } 119 | // await this._filesWatcher.dispose(); 120 | // } 121 | 122 | // this._filesWatcher = new FilesWatcher( 123 | // this._classConfig, 124 | // this._checkManager, 125 | // files, 126 | // this.documentManager 127 | // ); 128 | // await this._filesWatcher.init(); 129 | // } 130 | 131 | public async dispose(): Promise { 132 | await Promise.all( 133 | [...this._disposables.values()].map((d) => void d.dispose()) 134 | ); 135 | this._disposables.clear(); 136 | // await this._filesWatcher?.dispose(); 137 | // this._filesWatcher = null; 138 | } 139 | } 140 | 141 | // class FilesWatcher implements AsyncDisposable { 142 | // private _disposables: Disposable[] = []; 143 | 144 | // public constructor( 145 | // private readonly _classConfig: ClassConfig, 146 | // private readonly _checkManager: PHPStanCheckManager, 147 | // public readonly files: string[], 148 | // private readonly _documentManager: DocumentManager 149 | // ) {} 150 | 151 | // public async init(): Promise { 152 | // const workspaceFolders = await this._classConfig.workspaceFolders.get(); 153 | // if (!workspaceFolders) { 154 | // return; 155 | // } 156 | 157 | // const watcher = chokidar.watch( 158 | // workspaceFolders.default.fsPath + '/**/*.php', 159 | // { 160 | // ignoreInitial: true, 161 | // usePolling: false, 162 | // persistent: false, 163 | // awaitWriteFinish: { 164 | // stabilityThreshold: 100, 165 | // }, 166 | // } 167 | // ); 168 | // // eslint-disable-next-line @typescript-eslint/no-misused-promises 169 | // watcher.on('change', async (filename) => { 170 | // if (this.files.includes(filename)) { 171 | // await this._documentManager.onDocumentChange( 172 | // this._checkManager, 173 | // { 174 | // uri: URI.file(filename).toString(), 175 | // content: await fs.readFile(filename, 'utf8'), 176 | // languageId: 'php', 177 | // } 178 | // ); 179 | // } 180 | // }); 181 | // this._disposables.push({ 182 | // dispose: () => { 183 | // return watcher.close(); 184 | // }, 185 | // }); 186 | // } 187 | 188 | // public async dispose(): Promise { 189 | // await Promise.all(this._disposables.map((d) => void d.dispose())); 190 | // this._disposables = []; 191 | // } 192 | // } 193 | -------------------------------------------------------------------------------- /server/src/providers/hoverProvider.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Hover, 3 | HoverParams, 4 | ServerRequestHandler, 5 | } from 'vscode-languageserver'; 6 | import { getFileReport, providerEnabled } from './providerUtil'; 7 | import { HOVER_PROVIDER_PREFIX, log } from '../lib/log'; 8 | import type { ProviderArgs } from './providerUtil'; 9 | 10 | export function createHoverProvider( 11 | providerArgs: ProviderArgs 12 | ): ServerRequestHandler { 13 | return async (hoverParams, cancelToken) => { 14 | await providerArgs.onConnectionInitialized; 15 | if (cancelToken.isCancellationRequested) { 16 | return null; 17 | } 18 | 19 | if (!(await providerEnabled(providerArgs))) { 20 | return null; 21 | } 22 | 23 | const fileReport = await getFileReport( 24 | providerArgs, 25 | cancelToken, 26 | hoverParams.textDocument.uri 27 | ); 28 | if (!fileReport) { 29 | return null; 30 | } 31 | 32 | // Look for it 33 | for (const type of fileReport ?? []) { 34 | if ( 35 | type.pos.start.line === hoverParams.position.line && 36 | type.pos.start.char < hoverParams.position.character && 37 | type.pos.end.char > hoverParams.position.character 38 | ) { 39 | log(HOVER_PROVIDER_PREFIX, 'Found hover type'); 40 | return { 41 | contents: [`PHPStan: \`${type.typeDescr} $${type.name}\``], 42 | }; 43 | } 44 | } 45 | 46 | log(HOVER_PROVIDER_PREFIX, 'Hovering, no type found'); 47 | 48 | return null; 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /server/src/server.ts: -------------------------------------------------------------------------------- 1 | /* -------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | * ------------------------------------------------------------------------------------------ */ 5 | 6 | import type { 7 | Disposable, 8 | Hover, 9 | HoverParams, 10 | ServerRequestHandler, 11 | } from 'vscode-languageserver/node'; 12 | import { 13 | createConnection, 14 | ProposedFeatures, 15 | TextDocumentSyncKind, 16 | } from 'vscode-languageserver/node'; 17 | 18 | import { 19 | PromisedValue, 20 | ResolvedPromisedValue, 21 | type WorkspaceFolders, 22 | } from './lib/types'; 23 | import { startIntegratedChecker } from './start/startIntegratedChecker'; 24 | import type { PHPStanCheckManager } from './lib/phpstan/checkManager'; 25 | import { ProviderCheckHooks } from './providers/providerUtil'; 26 | import type { DocumentManager } from './lib/documentManager'; 27 | import { getEditorConfiguration } from './lib/editorConfig'; 28 | import type { PHPStanVersion } from './start/getVersion'; 29 | import { ConfigResolver } from './lib/configResolver'; 30 | import { initRequest } from './lib/requestChannels'; 31 | import { getVersion } from './start/getVersion'; 32 | import type { ClassConfig } from './lib/types'; 33 | import { log, SERVER_PREFIX } from './lib/log'; 34 | import { startPro } from './start/startPro'; 35 | import { StatusBar } from './lib/statusBar'; 36 | import { listenTest } from './lib/test'; 37 | import { URI } from 'vscode-uri'; 38 | import * as path from 'path'; 39 | 40 | async function main(): Promise { 41 | // Creates the LSP connection 42 | const connection = createConnection(ProposedFeatures.all); 43 | const disposables: Disposable[] = []; 44 | connection.onExit(() => { 45 | disposables.forEach((d) => void d.dispose()); 46 | }); 47 | const onConnectionInitialized = new Promise((resolve) => { 48 | connection.onInitialized(() => { 49 | resolve(); 50 | }); 51 | }); 52 | 53 | // Get the workspace folder this server is operating on 54 | const workspaceFolders = new PromisedValue(); 55 | const version = new PromisedValue(); 56 | const extensionPath = new PromisedValue(); 57 | 58 | connection.onInitialize((params) => { 59 | const uri = params.workspaceFolders?.[0].uri; 60 | if (uri) { 61 | const initializedFolders: WorkspaceFolders = { 62 | byName: {}, 63 | getForPath: (filePath: string) => { 64 | if (!path.isAbsolute(filePath)) { 65 | return undefined; 66 | } 67 | for (const folder of params.workspaceFolders ?? []) { 68 | const folderUri = URI.parse(folder.uri); 69 | if (filePath.startsWith(folderUri.fsPath)) { 70 | return folderUri; 71 | } 72 | } 73 | return undefined; 74 | }, 75 | }; 76 | if (params.workspaceFolders?.length === 1) { 77 | initializedFolders.default = URI.parse(uri); 78 | } 79 | for (const folder of params.workspaceFolders ?? []) { 80 | initializedFolders.byName[folder.name] = URI.parse(folder.uri); 81 | } 82 | workspaceFolders.set(initializedFolders); 83 | } 84 | return { 85 | capabilities: { 86 | textDocumentSync: { 87 | openClose: true, 88 | save: true, 89 | change: TextDocumentSyncKind.Full, 90 | }, 91 | hoverProvider: true, 92 | }, 93 | }; 94 | }); 95 | 96 | const hoverProvider = new PromisedValue< 97 | ServerRequestHandler 98 | >(); 99 | connection.onHover(async (...args) => { 100 | if (hoverProvider.isSet()) { 101 | const handler = await hoverProvider.get(); 102 | return handler(...args); 103 | } 104 | return null; 105 | }); 106 | connection.listen(); 107 | 108 | await onConnectionInitialized; 109 | log(SERVER_PREFIX, 'Language server ready'); 110 | const extensionStartedAt = new PromisedValue(); 111 | void connection 112 | .sendRequest(initRequest, { ready: true }) 113 | .then((response) => { 114 | extensionStartedAt.set(new Date(response.startedAt)); 115 | extensionPath.set(URI.parse(response.extensionPath)); 116 | }); 117 | 118 | // Create required values 119 | const editorConfigOverride: ClassConfig['editorConfigOverride'] = 120 | new ResolvedPromisedValue({}); 121 | const editorConfiguration = await getEditorConfiguration({ 122 | connection, 123 | workspaceFolders, 124 | editorConfigOverride: editorConfigOverride, 125 | }); 126 | const providerHooks = new ProviderCheckHooks( 127 | connection, 128 | version, 129 | workspaceFolders, 130 | extensionPath 131 | ); 132 | const statusBar = new StatusBar(connection); 133 | const classConfig: ClassConfig = { 134 | statusBar, 135 | connection, 136 | workspaceFolders, 137 | hooks: { 138 | provider: providerHooks, 139 | }, 140 | version, 141 | editorConfigOverride: editorConfigOverride, 142 | }; 143 | const configResolver = new ConfigResolver(classConfig); 144 | disposables.push(configResolver); 145 | 146 | // Check version 147 | void getVersion(classConfig).then((result) => { 148 | if (result.success) { 149 | classConfig.version.set(result.version); 150 | } 151 | }); 152 | 153 | let result: StartResult; 154 | if (editorConfiguration.pro) { 155 | result = await startPro( 156 | classConfig, 157 | configResolver, 158 | connection, 159 | disposables, 160 | onConnectionInitialized, 161 | workspaceFolders, 162 | editorConfigOverride 163 | ); 164 | } else { 165 | result = startIntegratedChecker( 166 | classConfig, 167 | configResolver, 168 | connection, 169 | disposables, 170 | onConnectionInitialized, 171 | workspaceFolders, 172 | extensionStartedAt 173 | ); 174 | } 175 | hoverProvider.set(result.hoverProvider); 176 | disposables.push( 177 | listenTest( 178 | connection, 179 | classConfig, 180 | result.documentManager, 181 | configResolver, 182 | result.checkManager 183 | ) 184 | ); 185 | } 186 | 187 | export interface StartResult { 188 | hoverProvider: ServerRequestHandler< 189 | HoverParams, 190 | Hover | undefined | null, 191 | never, 192 | void 193 | >; 194 | documentManager: DocumentManager; 195 | checkManager?: PHPStanCheckManager; 196 | } 197 | 198 | void main(); 199 | process.on('uncaughtException', () => { 200 | // Bug in ps-tree where it doesn't catch errors of the processes it spawns 201 | }); 202 | -------------------------------------------------------------------------------- /server/src/start/getVersion.ts: -------------------------------------------------------------------------------- 1 | import { ConfigurationManager } from '../lib/checkConfigManager'; 2 | import { SPAWN_ARGS } from '../../../shared/constants'; 3 | import type { ClassConfig } from '../lib/types'; 4 | import { log, SERVER_PREFIX } from '../lib/log'; 5 | import { spawn } from 'child_process'; 6 | 7 | export type PHPStanVersion = { 8 | minor: number; 9 | major: 1 | 2; 10 | }; 11 | 12 | export async function getVersion( 13 | classConfig: ClassConfig 14 | ): Promise< 15 | | { success: true; version: PHPStanVersion } 16 | | { success: false; error: string } 17 | > { 18 | // Test if we can get the PHPStan version 19 | const cwd = await ConfigurationManager.getCwd(classConfig, true); 20 | const workspaceRoot = (await classConfig.workspaceFolders.get())?.default; 21 | const binConfigResult = await ConfigurationManager.getBinComand( 22 | classConfig, 23 | cwd ?? undefined, 24 | workspaceRoot?.fsPath 25 | ); 26 | if (!binConfigResult.success) { 27 | return { 28 | success: false, 29 | error: binConfigResult.error, 30 | }; 31 | } 32 | 33 | return new Promise((resolve) => { 34 | const binArgs = binConfigResult.getBinCommand(['--version']); 35 | const proc = spawn(binArgs[0], binArgs.slice(1), { 36 | ...SPAWN_ARGS, 37 | }); 38 | 39 | let data = ''; 40 | proc.stdout.on('data', (chunk) => { 41 | data += chunk; 42 | }); 43 | proc.stderr.on('data', (chunk) => { 44 | data += chunk; 45 | }); 46 | 47 | proc.on('error', (err) => { 48 | log( 49 | SERVER_PREFIX, 50 | `Failed to get PHPStan version, is the path to your PHPStan binary correct? Error: ${err.message}` 51 | ); 52 | resolve({ 53 | success: false, 54 | error: `Failed to run: ${err.message}`, 55 | }); 56 | }); 57 | proc.on('close', (code) => { 58 | if (code !== null && code !== 0) { 59 | resolve({ 60 | success: false, 61 | error: `Exited with exit code ${code}: ${data}`, 62 | }); 63 | return; 64 | } 65 | 66 | log(SERVER_PREFIX, `PHPStan version: ${data}`); 67 | 68 | const versionMatch = /(\d+)\.(\d+)/.exec(data); 69 | if (!versionMatch) { 70 | // Assume 1.* if we can't find the version (bugged in v1.12.2) 71 | resolve({ 72 | success: true, 73 | version: { 74 | minor: 0, 75 | major: 1, 76 | }, 77 | }); 78 | return; 79 | } 80 | 81 | const [, major, minor] = versionMatch; 82 | resolve({ 83 | success: true, 84 | version: { 85 | major: parseInt(major) as 1 | 2, 86 | minor: parseInt(minor), 87 | }, 88 | }); 89 | }); 90 | }); 91 | } 92 | -------------------------------------------------------------------------------- /server/src/start/startIntegratedChecker.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ClassConfig, 3 | WorkspaceFolders, 4 | PromisedValue, 5 | } from '../lib/types'; 6 | import type { _Connection, Disposable } from 'vscode-languageserver'; 7 | import { PHPStanCheckManager } from '../lib/phpstan/checkManager'; 8 | import { createHoverProvider } from '../providers/hoverProvider'; 9 | import type { ProviderArgs } from '../providers/providerUtil'; 10 | import { getEditorConfiguration } from '../lib/editorConfig'; 11 | import type { ConfigResolver } from '../lib/configResolver'; 12 | import { DocumentManager } from '../lib/documentManager'; 13 | import { ResolvedPromisedValue } from '../lib/types'; 14 | import type { StartResult } from '../server'; 15 | import { wait } from '../../../shared/util'; 16 | import { Watcher } from '../lib/watcher'; 17 | 18 | export function startIntegratedChecker( 19 | classConfig: ClassConfig, 20 | configResolver: ConfigResolver, 21 | connection: _Connection, 22 | disposables: Disposable[], 23 | onConnectionInitialized: Promise, 24 | workspaceFolders: PromisedValue, 25 | startedAt: PromisedValue 26 | ): StartResult { 27 | const checkManager: PHPStanCheckManager = new PHPStanCheckManager( 28 | classConfig, 29 | configResolver, 30 | () => documentManager 31 | ); 32 | const documentManager = new DocumentManager(classConfig, { 33 | phpstan: checkManager, 34 | onConnectionInitialized, 35 | watcher: new Watcher(classConfig, checkManager), 36 | configResolver, 37 | }); 38 | disposables.push(checkManager, documentManager); 39 | 40 | const providerArgs: ProviderArgs = { 41 | connection, 42 | hooks: classConfig.hooks.provider, 43 | phpstan: checkManager, 44 | workspaceFolders, 45 | onConnectionInitialized, 46 | documents: documentManager, 47 | }; 48 | 49 | void (async () => { 50 | const startedAtTime = await startedAt.get(); 51 | const serverLiveFor = Date.now() - startedAtTime.getTime(); 52 | // Wait a while after start with checking so as to now tax the system too much 53 | await wait(Math.max(5000 - serverLiveFor, 0)); 54 | const configuration = await getEditorConfiguration({ 55 | connection, 56 | workspaceFolders, 57 | editorConfigOverride: new ResolvedPromisedValue({}), 58 | }); 59 | if ( 60 | configuration.enabled && 61 | !configuration.singleFileMode && 62 | checkManager.operationCount === 0 63 | ) { 64 | void checkManager.checkWithDebounce( 65 | undefined, 66 | null, 67 | 'Initial check', 68 | null 69 | ); 70 | } 71 | })(); 72 | 73 | return { 74 | hoverProvider: createHoverProvider(providerArgs), 75 | checkManager, 76 | documentManager, 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /server/src/start/startPro.ts: -------------------------------------------------------------------------------- 1 | import { 2 | statusBarNotification, 3 | phpstanProNotification, 4 | } from '../lib/notificationChannels'; 5 | import type { 6 | ClassConfig, 7 | PromisedValue, 8 | WorkspaceFolders, 9 | } from '../lib/types'; 10 | import type { _Connection, Disposable } from 'vscode-languageserver'; 11 | import { createHoverProvider } from '../providers/hoverProvider'; 12 | import type { ProviderArgs } from '../providers/providerUtil'; 13 | import type { ConfigResolver } from '../lib/configResolver'; 14 | import { Commands } from '../../../shared/commands/defs'; 15 | import { DocumentManager } from '../lib/documentManager'; 16 | import { launchPro } from '../lib/phpstan/pro/pro'; 17 | import type { StartResult } from '../server'; 18 | 19 | export async function startPro( 20 | classConfig: ClassConfig, 21 | configResolver: ConfigResolver, 22 | connection: _Connection, 23 | disposables: Disposable[], 24 | onConnectionInitialized: Promise, 25 | workspaceFolders: PromisedValue, 26 | editorConfigOverride: PromisedValue> 27 | ): Promise { 28 | void connection.sendNotification(statusBarNotification, { 29 | type: 'fallback', 30 | text: 'PHPStan Pro starting...', 31 | }); 32 | const pro = await launchPro(classConfig, configResolver, (progress) => { 33 | void connection.sendNotification(statusBarNotification, { 34 | type: 'fallback', 35 | text: `PHPStan Pro starting ${progress.done}/${progress.total} (${progress.percentage}%)`, 36 | }); 37 | }); 38 | if (!pro.success()) { 39 | void connection.window.showErrorMessage( 40 | `Failed to start PHPStan Pro: ${pro.error ?? '?'}` 41 | ); 42 | void connection.sendNotification(statusBarNotification, { 43 | type: 'fallback', 44 | text: undefined, 45 | }); 46 | } else if (!(await pro.value.getPort())) { 47 | void connection.window.showErrorMessage( 48 | 'Failed to find PHPStan Pro port' 49 | ); 50 | void connection.sendNotification(statusBarNotification, { 51 | type: 'fallback', 52 | text: undefined, 53 | }); 54 | } else { 55 | disposables.push(pro.value); 56 | const port = (await pro.value.getPort())!; 57 | void connection.sendNotification(phpstanProNotification, { 58 | type: 'setPort', 59 | port: port, 60 | }); 61 | if (!(await pro.value.isLoggedIn())) { 62 | void connection.sendNotification(phpstanProNotification, { 63 | type: 'requireLogin', 64 | }); 65 | } 66 | void connection.sendNotification(statusBarNotification, { 67 | type: 'fallback', 68 | text: 'PHPStan Pro running', 69 | command: Commands.OPEN_PHPSTAN_PRO, 70 | }); 71 | } 72 | 73 | const documentManager = new DocumentManager( 74 | { 75 | connection: connection, 76 | workspaceFolders: workspaceFolders, 77 | editorConfigOverride, 78 | }, 79 | { 80 | onConnectionInitialized, 81 | watcher: null, 82 | configResolver, 83 | } 84 | ); 85 | disposables.push(documentManager); 86 | 87 | const providerArgs: ProviderArgs = { 88 | connection, 89 | hooks: classConfig.hooks.provider, 90 | workspaceFolders, 91 | onConnectionInitialized, 92 | documents: documentManager, 93 | }; 94 | 95 | return { 96 | hoverProvider: createHoverProvider(providerArgs), 97 | documentManager, 98 | }; 99 | } 100 | -------------------------------------------------------------------------------- /server/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | # bun ./bun.lockb --hash: 02349ACB444B952A-9c5563f65855a4ff-FE06CC226815F889-b8ae883f3cb60423 4 | 5 | 6 | "@types/node@*": 7 | version "20.11.30" 8 | resolved "https://registry.npmjs.org/@types/node/-/node-20.11.30.tgz" 9 | integrity sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw== 10 | dependencies: 11 | undici-types "~5.26.4" 12 | 13 | "@types/ws@^8.5.9": 14 | version "8.5.10" 15 | resolved "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz" 16 | integrity sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A== 17 | dependencies: 18 | "@types/node" "*" 19 | 20 | php-parser@^3.1.5: 21 | version "3.1.5" 22 | resolved "https://registry.npmjs.org/php-parser/-/php-parser-3.1.5.tgz" 23 | integrity sha512-jEY2DcbgCm5aclzBdfW86GM6VEIWcSlhTBSHN1qhJguVePlYe28GhwS0yoeLYXpM2K8y6wzLwrbq814n2PHSoQ== 24 | 25 | tmp@^0.2.0: 26 | version "0.2.3" 27 | resolved "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz" 28 | integrity sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w== 29 | 30 | tmp-promise@^3.0.3: 31 | version "3.0.3" 32 | resolved "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz" 33 | integrity sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ== 34 | dependencies: 35 | tmp "^0.2.0" 36 | 37 | undici-types@~5.26.4: 38 | version "5.26.5" 39 | resolved "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz" 40 | integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== 41 | 42 | vscode-jsonrpc@8.1.0: 43 | version "8.1.0" 44 | resolved "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.1.0.tgz" 45 | integrity sha512-6TDy/abTQk+zDGYazgbIPc+4JoXdwC8NHU9Pbn4UJP1fehUyZmM4RHp5IthX7A6L5KS30PRui+j+tbbMMMafdw== 46 | 47 | vscode-languageserver@^8.0.1: 48 | version "8.1.0" 49 | resolved "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-8.1.0.tgz" 50 | integrity sha512-eUt8f1z2N2IEUDBsKaNapkz7jl5QpskN2Y0G01T/ItMxBxw1fJwvtySGB9QMecatne8jFIWJGWI61dWjyTLQsw== 51 | dependencies: 52 | vscode-languageserver-protocol "3.17.3" 53 | 54 | vscode-languageserver-protocol@3.17.3: 55 | version "3.17.3" 56 | resolved "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.3.tgz" 57 | integrity sha512-924/h0AqsMtA5yK22GgMtCYiMdCOtWTSGgUOkgEDX+wk2b0x4sAfLiO4NxBxqbiVtz7K7/1/RgVrVI0NClZwqA== 58 | dependencies: 59 | vscode-jsonrpc "8.1.0" 60 | vscode-languageserver-types "3.17.3" 61 | 62 | vscode-languageserver-textdocument@^1.0.5: 63 | version "1.0.11" 64 | resolved "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.11.tgz" 65 | integrity sha512-X+8T3GoiwTVlJbicx/sIAF+yuJAqz8VvwJyoMVhwEMoEKE/fkDmrqUgDMyBECcM2A2frVZIUj5HI/ErRXCfOeA== 66 | 67 | vscode-languageserver-types@3.17.3: 68 | version "3.17.3" 69 | resolved "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.3.tgz" 70 | integrity sha512-SYU4z1dL0PyIMd4Vj8YOqFvHu7Hz/enbWtpfnVbJHU4Nd1YNYx8u0ennumc6h48GQNeOLxmwySmnADouT/AuZA== 71 | 72 | vscode-uri@^3.0.3: 73 | version "3.0.8" 74 | resolved "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz" 75 | integrity sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw== 76 | 77 | ws@^8.14.2: 78 | version "8.16.0" 79 | resolved "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz" 80 | integrity sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ== 81 | -------------------------------------------------------------------------------- /shared/commands/defs.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CommandDefinition, 3 | ViewDefinition, 4 | ConfigurationDefinition, 5 | } from 'vscode-generate-package-json'; 6 | 7 | export enum Commands { 8 | SCAN_FILE_FOR_ERRORS = 'phpstan.scanFileForErrors', 9 | SCAN_PROJECT = 'phpstan.scanProjectForErrors', 10 | SCAN_CURRENT_PROJECT = 'phpstan.scanCurrentProjectForErrors', 11 | RELOAD = 'phpstan.reload', 12 | NEXT_ERROR = 'phpstan.nextError', 13 | PREVIOUS_ERROR = 'phpstan.previousError', 14 | OPEN_PHPSTAN_PRO = 'phpstan.openPhpstanPro', 15 | LAUNCH_SETUP = 'phpstan.launchSetup', 16 | DOWNLOAD_DEBUG_DATA = 'phpstan.downloadDebugData', 17 | CLEAR_ERRORS = 'phpstan.clearErrors', 18 | SHOW_OUTPUT_CHANNEL = 'phpstan.showOutputChannel', 19 | } 20 | 21 | export const commands: Record = { 22 | [Commands.SCAN_FILE_FOR_ERRORS]: { 23 | title: 'Scan current file for errors', 24 | inCommandPalette: true, 25 | }, 26 | [Commands.CLEAR_ERRORS]: { 27 | title: 'Clear errors', 28 | inCommandPalette: true, 29 | }, 30 | [Commands.SCAN_PROJECT]: { 31 | title: 'Scan project for errors', 32 | inCommandPalette: true, 33 | }, 34 | [Commands.SCAN_CURRENT_PROJECT]: { 35 | title: 'Scan current project for errors', 36 | inCommandPalette: true, 37 | }, 38 | [Commands.RELOAD]: { 39 | title: 'Reload language server', 40 | inCommandPalette: true, 41 | }, 42 | [Commands.NEXT_ERROR]: { 43 | title: 'Go to next error', 44 | inCommandPalette: true, 45 | }, 46 | [Commands.PREVIOUS_ERROR]: { 47 | title: 'Go to previous error', 48 | inCommandPalette: true, 49 | }, 50 | [Commands.OPEN_PHPSTAN_PRO]: { 51 | title: 'Open PHPStan Pro in browser', 52 | inCommandPalette: true, 53 | }, 54 | [Commands.LAUNCH_SETUP]: { 55 | title: 'Launch setup', 56 | inCommandPalette: true, 57 | }, 58 | [Commands.DOWNLOAD_DEBUG_DATA]: { 59 | title: 'Download debug data', 60 | inCommandPalette: true, 61 | }, 62 | [Commands.SHOW_OUTPUT_CHANNEL]: { 63 | title: 'Show output channel', 64 | inCommandPalette: false, 65 | }, 66 | }; 67 | 68 | export const config = { 69 | 'phpstan.singleFileMode': { 70 | jsonDefinition: { 71 | type: 'boolean', 72 | description: 73 | "Whether to scan only the file that is being saved, instead of the whole project. This is not recommended since it busts the cache. Only use this if your computer can't handle a full-project scan", 74 | default: false, 75 | }, 76 | }, 77 | 'phpstan.binPath': { 78 | jsonDefinition: { 79 | type: 'string', 80 | default: 'vendor/bin/phpstan', 81 | description: 'Path to the PHPStan binary', 82 | }, 83 | }, 84 | 'phpstan.binCommand': { 85 | jsonDefinition: { 86 | type: 'array', 87 | examples: [['phpstan'], ['lando', 'phpstan']], 88 | items: { 89 | type: 'string', 90 | }, 91 | description: 92 | 'PHPStan command. Use this instead of "binPath" if, for example, the phpstan binary is in your path', 93 | }, 94 | }, 95 | 'phpstan.configFile': { 96 | jsonDefinition: { 97 | type: 'string', 98 | default: 'phpstan.neon,phpstan.neon.dist,phpstan.dist.neon', 99 | examples: [ 100 | 'phpstan.neon', 101 | 'backend/phpstan.neon', 102 | 'phpstan.neon,phpstan.neon.dist', 103 | ], 104 | description: 105 | 'Filename or path to the config file (use a comma-separated list to resolve in order)', 106 | }, 107 | }, 108 | 'phpstan.paths': { 109 | jsonDefinition: { 110 | type: 'object', 111 | default: {}, 112 | __shape: '' as unknown as Record, 113 | examples: [ 114 | { 115 | '/path/to/hostFolder': '/path/in/dockerContainer', 116 | }, 117 | ], 118 | description: 119 | 'Path mapping for scanned files. Allows for rewriting paths for for example Docker.', 120 | }, 121 | }, 122 | 'phpstan.dockerContainerName': { 123 | jsonDefinition: { 124 | type: 'string', 125 | description: 'Name of the Docker container to use for scanning', 126 | examples: ['docker-phpstan-php-1'], 127 | }, 128 | }, 129 | 'phpstan.rootDir': { 130 | jsonDefinition: { 131 | type: 'string', 132 | description: 'Path to the root directory', 133 | }, 134 | }, 135 | 'phpstan.options': { 136 | jsonDefinition: { 137 | type: 'array', 138 | default: [], 139 | items: { 140 | type: 'string', 141 | }, 142 | description: 143 | 'Extra commandline options to be passed to PHPStan. Supports substituting ${workspaceFolder}', 144 | }, 145 | }, 146 | 'phpstan.enableStatusBar': { 147 | jsonDefinition: { 148 | type: 'boolean', 149 | default: true, 150 | description: 'Show the status bar while scanning', 151 | }, 152 | }, 153 | 'phpstan.memoryLimit': { 154 | jsonDefinition: { 155 | type: 'string', 156 | default: '1G', 157 | description: 'Memory limit to use', 158 | }, 159 | }, 160 | 'phpstan.enabled': { 161 | jsonDefinition: { 162 | type: 'boolean', 163 | description: 'Whether to enable the on-save checker', 164 | default: true, 165 | }, 166 | }, 167 | 'phpstan.projectTimeout': { 168 | jsonDefinition: { 169 | type: 'number', 170 | description: 171 | 'Timeout in milliseconds for a full project check. After this time the checking process is canceled', 172 | default: 300000, 173 | }, 174 | }, 175 | 'phpstan.timeout': { 176 | jsonDefinition: { 177 | type: 'number', 178 | description: 179 | 'Timeout in milliseconds for a file check. After this time the checking process is canceled', 180 | default: 300000, 181 | }, 182 | }, 183 | 'phpstan.suppressTimeoutMessage': { 184 | jsonDefinition: { 185 | type: 'boolean', 186 | description: 'Stop showing an error when the operation times out', 187 | default: false, 188 | }, 189 | }, 190 | 'phpstan.showProgress': { 191 | jsonDefinition: { 192 | type: 'boolean', 193 | description: 194 | 'Show the progress bar when performing a single-file check', 195 | default: false, 196 | }, 197 | }, 198 | 'phpstan.showTypeOnHover': { 199 | jsonDefinition: { 200 | type: 'boolean', 201 | description: 202 | 'Show type information on hover. Disable this if you have a custom PHPStan binary that runs on another filesystem (such as Docker) or if you run into caching problems. Does not work with PHPStan Pro enabled or for PHPStan version < 1.8.0.', 203 | default: false, 204 | }, 205 | }, 206 | 'phpstan.enableLanguageServer': { 207 | jsonDefinition: { 208 | type: 'boolean', 209 | description: 210 | 'Enable language server that provides on-hover type information. Disable this if you have a custom PHPStan binary that runs on another filesystem (such as Docker) or if you run into caching problems. Does not work with PHPStan Pro enabled or for PHPStan version < 1.8.0.', 211 | default: false, 212 | deprecationMessage: 'Use phpstan.showTypeOnHover instead', 213 | }, 214 | }, 215 | 'phpstan.ignoreErrors': { 216 | jsonDefinition: { 217 | type: 'array', 218 | description: 219 | "An array of regular expressions to ignore in PHPStan's error output. If PHPStan outputs some warnings/errors in stderr that can be ignored, put them in here and they'll no longer cause the process to exit with an error.", 220 | default: ['Xdebug: .*'], 221 | items: { 222 | type: 'string', 223 | }, 224 | examples: [['Xdebug: .*']], 225 | }, 226 | }, 227 | 'phpstan.suppressWorkspaceMessage': { 228 | jsonDefinition: { 229 | type: 'boolean', 230 | description: 231 | 'Stop showing an error when using a multi-workspace project', 232 | default: false, 233 | }, 234 | }, 235 | 'phpstan.pro': { 236 | jsonDefinition: { 237 | type: 'boolean', 238 | description: 239 | 'Use PHPStan Pro under the hood (if you have a license)', 240 | default: false, 241 | }, 242 | }, 243 | 'phpstan.tmpDir': { 244 | jsonDefinition: { 245 | type: 'string', 246 | description: 247 | 'Path to the PHPStan TMP directory. Lets PHPStan determine the TMP directory if not set.', 248 | }, 249 | }, 250 | 'phpstan.checkValidity': { 251 | jsonDefinition: { 252 | type: 'boolean', 253 | description: 254 | 'Check the validity of the PHP code before checking it with PHPStan. This is recommended only if you have autoSave enabled or for some other reason save syntactically invalid code. PHPStan tends to invalidate its cache when checking an invalid file, leading to a slower experience.', 255 | default: false, 256 | }, 257 | }, 258 | } as const; 259 | 260 | export const views: Record = {}; 261 | export const commandDefinitions = Commands; 262 | export const configuration = config as Record; 263 | -------------------------------------------------------------------------------- /shared/config.ts: -------------------------------------------------------------------------------- 1 | import type { GetConfigurationType } from 'vscode-generate-package-json'; 2 | import type { config } from './commands/defs'; 3 | 4 | export type ConfigSettingsWithoutPrefix = { 5 | [K in keyof ConfigSettings as K extends `phpstan.${infer R}` 6 | ? R 7 | : unknown]: ConfigSettings[K]; 8 | }; 9 | export type ConfigSettings = Omit< 10 | GetConfigurationType, 11 | 'phpstan.ignoreErrors' | 'phpstan.enableLanguageServer' 12 | > & { 13 | 'phpstan.ignoreErrors': (string | RegExp)[]; 14 | // Legacy setting 15 | 'phpstan.proTmpDir'?: string; 16 | /** @deprecated */ 17 | 'phpstan.enableLanguageServer'?: boolean; 18 | }; 19 | 20 | export type DockerConfigSettings = { 21 | 'docker.environment': Record; 22 | }; 23 | 24 | export type ExternalConfigSettings = DockerConfigSettings; 25 | -------------------------------------------------------------------------------- /shared/constants.ts: -------------------------------------------------------------------------------- 1 | import path = require('path'); 2 | 3 | export const EXTENSION_ID = 'sanderronde.phpstan-vscode'; 4 | // Disable cancelling of operations. Handy when stepping 5 | // through an action as VSCode cancels long-running operations 6 | export const NO_CANCEL_OPERATIONS = false; 7 | // This file will end up in root/out/ so it's just one level back 8 | const ROOT_FOLDER = path.join(__dirname, '..'); 9 | export const MAX_HOVER_WAIT_TIME = 60000; 10 | export const HOVER_WAIT_CHUNK_TIME = 50; 11 | export const TREE_FETCHER_FILE = path.join(ROOT_FOLDER, 'php/TreeFetcher.php'); 12 | export const PHPSTAN_1_NEON_FILE = path.join(ROOT_FOLDER, 'php/config.neon'); 13 | export const PHPSTAN_2_NEON_FILE = path.join(ROOT_FOLDER, 'php/config.2.neon'); 14 | // Hard limit, process should never take longer than this 15 | export const PROCESS_TIMEOUT = 1000 * 60 * 15; 16 | export const CHECK_DEBOUNCE = 100; 17 | export const TELEMETRY_CONNECTION_STRING = 18 | 'InstrumentationKey=aea7172d-4723-4bae-badb-52110a1d1519;IngestionEndpoint=https://eastus-8.in.applicationinsights.azure.com/;LiveEndpoint=https://eastus.livediagnostics.monitor.azure.com/;ApplicationId=194d83e0-cb10-4bf0-ac18-60ed830a19d8'; 19 | 20 | export const SPAWN_ARGS = { 21 | shell: process.platform === 'win32', 22 | windowsVerbatimArguments: true, 23 | }; 24 | -------------------------------------------------------------------------------- /shared/debug.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | 3 | const sanitizedNames = new Map(); 4 | 5 | function generateAlphaNumericString(input: string): string { 6 | const characters = 7 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 8 | 9 | // Create a simple hash of the input string 10 | let hash = 0; 11 | for (let i = 0; i < input.length; i++) { 12 | hash = (hash << 5) - hash + input.charCodeAt(i); 13 | hash = hash & hash; // Convert to 32-bit integer 14 | } 15 | 16 | // Use the hash to generate a deterministic string 17 | let result = ''; 18 | const hashStr = Math.abs(hash).toString(); 19 | for (let i = 0; i < 10; i++) { 20 | const index = parseInt(hashStr[i % hashStr.length]) % characters.length; 21 | result += characters[index]; 22 | } 23 | return result; 24 | } 25 | 26 | function sanitizeString(str: string): string { 27 | if (!sanitizedNames.has(str)) { 28 | const replacement = generateAlphaNumericString(str); 29 | sanitizedNames.set(str, replacement); 30 | return replacement; 31 | } 32 | return sanitizedNames.get(str)!; 33 | } 34 | 35 | export function sanitizeFilePath(filePath: string): string { 36 | if ( 37 | !filePath.includes('/') && 38 | !filePath.includes('\\') && 39 | !filePath.includes('\\\\') && 40 | !path.extname(filePath) 41 | ) { 42 | return filePath; 43 | } 44 | 45 | const protocolMatch = /^[^:]+:\/\//.exec(filePath); 46 | const protocol = protocolMatch ? protocolMatch[0] : ''; 47 | const pathWithoutProtocol = protocol 48 | ? filePath.slice(protocol.length) 49 | : filePath; 50 | 51 | const fileExtension = path.extname(pathWithoutProtocol); 52 | const fileWithoutExtension = pathWithoutProtocol.slice( 53 | 0, 54 | pathWithoutProtocol.length - fileExtension.length 55 | ); 56 | const fileParts = fileWithoutExtension 57 | .split('/') 58 | .flatMap((part) => part.split('\\')); 59 | 60 | return protocol + fileParts.map(sanitizeString).join('/') + fileExtension; 61 | } 62 | -------------------------------------------------------------------------------- /shared/neon.ts: -------------------------------------------------------------------------------- 1 | import { decode, Map as NeonMap } from 'neon-js'; 2 | import type { Neon } from 'neon-js'; 3 | import fs from 'fs/promises'; 4 | import path from 'path'; 5 | 6 | async function readNeonFile( 7 | filePath: string, 8 | onError: (error: Error) => void 9 | ): Promise { 10 | const parsed = await (async () => { 11 | try { 12 | return decode(await fs.readFile(filePath, 'utf8')); 13 | } catch (error) { 14 | onError(error as Error); 15 | return null; 16 | } 17 | })(); 18 | if (!parsed) { 19 | return []; 20 | } 21 | 22 | const output: Neon[] = [parsed]; 23 | if (!(parsed instanceof NeonMap)) { 24 | return output; 25 | } 26 | 27 | if (parsed.has('includes')) { 28 | const includes = parsed.get('includes'); 29 | if (!(includes instanceof NeonMap) || !includes.isList()) { 30 | return output; 31 | } 32 | 33 | for (const file of includes.values()) { 34 | if (typeof file !== 'string') { 35 | continue; 36 | } 37 | 38 | if (path.isAbsolute(file)) { 39 | output.push(...(await readNeonFile(file, onError))); 40 | } else { 41 | output.push( 42 | ...(await readNeonFile( 43 | path.join(path.dirname(filePath), file), 44 | onError 45 | )) 46 | ); 47 | } 48 | } 49 | } 50 | 51 | return output; 52 | } 53 | 54 | export class ParsedConfigFile { 55 | public contents!: Neon[]; 56 | public paths: string[] = []; 57 | public excludePaths: string[] = []; 58 | 59 | private constructor(public filePath: string) {} 60 | 61 | public static async from( 62 | filePath: string, 63 | onError: (error: Error) => void 64 | ): Promise { 65 | const parsedFile = new ParsedConfigFile(filePath); 66 | parsedFile.contents = await readNeonFile(filePath, onError); 67 | 68 | const { paths, excludePaths } = this._getIncludedPaths( 69 | parsedFile.contents 70 | ); 71 | parsedFile.paths = paths; 72 | parsedFile.excludePaths = excludePaths; 73 | return parsedFile; 74 | } 75 | 76 | private static _getIncludedPaths(neonFiles: Neon[]): { 77 | paths: string[]; 78 | excludePaths: string[]; 79 | } { 80 | const paths: string[] = []; 81 | const excludePaths: string[] = []; 82 | for (const neonFile of neonFiles) { 83 | if (!(neonFile instanceof NeonMap)) { 84 | continue; 85 | } 86 | 87 | if (!neonFile.has('parameters')) { 88 | continue; 89 | } 90 | 91 | const parameters = neonFile.get('parameters'); 92 | if (!(parameters instanceof NeonMap)) { 93 | continue; 94 | } 95 | 96 | if (parameters.has('paths')) { 97 | paths.push(...this._parsePaths(parameters.get('paths'))); 98 | } 99 | if (parameters.has('excludePaths')) { 100 | excludePaths.push( 101 | ...this._parsePaths(parameters.get('excludePaths')) 102 | ); 103 | } 104 | } 105 | 106 | return { 107 | paths, 108 | excludePaths, 109 | }; 110 | } 111 | 112 | private static _parsePaths(pathsMap: Neon): string[] { 113 | if (!(pathsMap instanceof NeonMap)) { 114 | return []; 115 | } 116 | 117 | const paths: string[] = []; 118 | if (pathsMap.isList()) { 119 | for (const path of pathsMap.values()) { 120 | if (typeof path !== 'string') { 121 | continue; 122 | } 123 | 124 | paths.push(path); 125 | } 126 | return paths; 127 | } 128 | 129 | if (pathsMap.has('analyse')) { 130 | paths.push(...this._parsePaths(pathsMap.get('analyse'))); 131 | } 132 | if (pathsMap.has('analyseAndScan')) { 133 | paths.push(...this._parsePaths(pathsMap.get('analyseAndScan'))); 134 | } 135 | 136 | return paths; 137 | } 138 | 139 | public isInPaths(filePath: string): boolean { 140 | if (filePath === this.filePath) { 141 | return true; 142 | } 143 | 144 | function fnmatch(pattern: string, string: string): boolean { 145 | // Escape special regex characters 146 | let regexPattern = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&'); 147 | 148 | // Convert shell wildcard characters to regex equivalents 149 | regexPattern = regexPattern 150 | .replace(/\*/g, '.*') 151 | .replace(/\?/g, '.'); 152 | 153 | // Add start and end anchors 154 | regexPattern = '^' + regexPattern; 155 | 156 | // Create and test the regular expression 157 | const regex = new RegExp(regexPattern); 158 | return regex.test(string); 159 | } 160 | 161 | const configFileDir = path.dirname(this.filePath); 162 | for (const excludePath of this.excludePaths) { 163 | if (fnmatch(path.join(configFileDir, excludePath), filePath)) { 164 | return false; 165 | } 166 | } 167 | 168 | for (const includePath of this.paths) { 169 | if (fnmatch(path.join(configFileDir, includePath), filePath)) { 170 | return true; 171 | } 172 | } 173 | 174 | return false; 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /shared/notificationChannels.ts: -------------------------------------------------------------------------------- 1 | import type { ReportedErrors } from '../server/src/lib/phpstan/check'; 2 | import type { OperationStatus } from './statusBar'; 3 | import type { Commands } from './commands/defs'; 4 | 5 | export enum NotificationChannel { 6 | STATUS_BAR = 'phpstan.statusBar', 7 | WATCHER = 'phpstan.watcher', 8 | COMMAND = 'phpstan.command', 9 | ERROR = 'phpstan.error', 10 | SPAWNER = 'phpstan.spawner', 11 | DEBUG = 'phpstan.debug', 12 | PHPSTAN_PRO = 'phpstan.phpstanPro', 13 | TEST = 'phpstan.test', 14 | } 15 | 16 | export interface WatcherNotificationFileData { 17 | uri: string; 18 | content: string; 19 | languageId: string; 20 | } 21 | 22 | export type WatcherNotificationType = 23 | | { 24 | operation: 'open'; 25 | file: WatcherNotificationFileData; 26 | check: boolean; 27 | } 28 | | { 29 | operation: 'change'; 30 | file: WatcherNotificationFileData; 31 | } 32 | | { 33 | operation: 'save'; 34 | file: WatcherNotificationFileData; 35 | } 36 | | { 37 | operation: 'setActive'; 38 | file: WatcherNotificationFileData; 39 | } 40 | | { 41 | operation: 'close'; 42 | file: WatcherNotificationFileData; 43 | } 44 | | { 45 | operation: 'check'; 46 | file: WatcherNotificationFileData; 47 | } 48 | | { 49 | operation: 'checkProject'; 50 | file: WatcherNotificationFileData | null; 51 | } 52 | | { 53 | operation: 'checkAllProjects'; 54 | } 55 | | { 56 | operation: 'onConfigChange'; 57 | file: WatcherNotificationFileData | null; 58 | } 59 | | { 60 | operation: 'clear'; 61 | }; 62 | 63 | export interface CommandNotificationType { 64 | commandName: string; 65 | commandArgs: string[]; 66 | } 67 | 68 | export interface LogNotificationType { 69 | data: string[]; 70 | } 71 | 72 | export interface StatusBarProgress { 73 | percentage: number; 74 | total: number; 75 | done: number; 76 | } 77 | 78 | export type StatusBarNotificationType = 79 | | { 80 | opId: number; 81 | type: 'new'; 82 | tooltip: string; 83 | } 84 | | { 85 | opId: number; 86 | progress: StatusBarProgress; 87 | type: 'progress'; 88 | tooltip: string; 89 | } 90 | | { 91 | opId: number; 92 | type: 'done'; 93 | result: OperationStatus; 94 | } 95 | | { 96 | type: 'fallback'; 97 | text: string | undefined; 98 | command?: Commands; 99 | }; 100 | 101 | export interface ProcessNotificationType { 102 | pid: number; 103 | timeout: number; 104 | children?: number[]; 105 | } 106 | 107 | export type PHPStanProNotificationType = 108 | | { 109 | type: 'setPort'; 110 | port: number; 111 | } 112 | | { 113 | type: 'requireLogin'; 114 | }; 115 | 116 | export interface ErrorNotificationType { 117 | diagnostics: ReportedErrors; 118 | } 119 | 120 | export interface DebugNotificationType { 121 | debugData: { type: string; data: unknown[] }; 122 | } 123 | -------------------------------------------------------------------------------- /shared/requestChannels.ts: -------------------------------------------------------------------------------- 1 | import type { WatcherNotificationFileData } from './notificationChannels'; 2 | import type { ConfigSettingsWithoutPrefix } from './config'; 3 | 4 | export enum RequestChannel { 5 | INIT = 'phpstan.init', 6 | TEST_RUN = 'phpstan.testRun', 7 | CONFIG_RESOLVE = 'phpstan.configResolve', 8 | FIND_FILES = 'phpstan.findFiles', 9 | } 10 | 11 | export interface InitRequestType { 12 | request: { 13 | ready: boolean; 14 | }; 15 | response: { 16 | extensionPath: string; 17 | startedAt: number; 18 | }; 19 | error: never; 20 | } 21 | 22 | export interface TestRunRequestType { 23 | request: Partial & { 24 | dryRun: boolean; 25 | file?: WatcherNotificationFileData; 26 | }; 27 | response: 28 | | { 29 | success: true; 30 | } 31 | | { 32 | success: false; 33 | error: string; 34 | }; 35 | error: never; 36 | } 37 | 38 | export interface ConfigResolveRequestType { 39 | request: { 40 | uri: string; 41 | }; 42 | response: { 43 | uri: string | null; 44 | }; 45 | error: never; 46 | } 47 | 48 | export interface FindFilesRequestType { 49 | request: { 50 | pattern: string; 51 | }; 52 | response: { 53 | files: string[]; 54 | }; 55 | error: never; 56 | } 57 | -------------------------------------------------------------------------------- /shared/statusBar.ts: -------------------------------------------------------------------------------- 1 | export enum OperationStatus { 2 | ERROR = 'Error', 3 | // AKA superseded 4 | CANCELLED = 'Canceled', 5 | SUCCESS = 'Success', 6 | KILLED = 'Killed', 7 | } 8 | -------------------------------------------------------------------------------- /shared/types.ts: -------------------------------------------------------------------------------- 1 | export interface AsyncDisposable { 2 | dispose: () => Promise; 3 | } 4 | -------------------------------------------------------------------------------- /shared/util.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | SpawnOptionsWithStdioTuple, 3 | StdioNull, 4 | StdioPipe, 5 | } from 'child_process'; 6 | import type { Disposable } from 'vscode'; 7 | import { spawn } from 'child_process'; 8 | import * as fs from 'fs/promises'; 9 | import * as crypto from 'crypto'; 10 | import { constants } from 'fs'; 11 | import * as path from 'path'; 12 | import * as os from 'os'; 13 | 14 | /** 15 | * Assert that forces TS to check whether a route is reachable 16 | */ 17 | export function assertUnreachable(x: never): never { 18 | throw new Error( 19 | `Value of type '${typeof x}' was not expected and should be unreachable` 20 | ); 21 | } 22 | 23 | export async function wait(time: number): Promise { 24 | await new Promise((resolve) => setTimeout(resolve, time)); 25 | } 26 | 27 | export async function waitPeriodical( 28 | totalTime: number, 29 | periodTime: number, 30 | callback: () => R | null 31 | ): Promise { 32 | let passedTime = 0; 33 | while (passedTime < totalTime) { 34 | const result = callback(); 35 | if (result !== null) { 36 | return result; 37 | } 38 | const waitedTime = Math.min(periodTime, totalTime - passedTime); 39 | await wait(waitedTime); 40 | passedTime += waitedTime; 41 | } 42 | return null; 43 | } 44 | 45 | export interface PromiseObject { 46 | promise: Promise; 47 | resolve: (result: R) => void; 48 | } 49 | 50 | export function createPromise(): Promise> { 51 | return new Promise<{ 52 | promise: Promise; 53 | resolve: (result: R) => void; 54 | }>((resolve) => { 55 | const promise = new Promise((_resolve) => { 56 | resolve({ 57 | resolve: _resolve, 58 | get promise() { 59 | return promise; 60 | }, 61 | }); 62 | }); 63 | }); 64 | } 65 | 66 | export function withTimeout(timeoutConfig: { 67 | onTimeout: () => R; 68 | onError: (error: Error) => R; 69 | promise: Promise

; 70 | timeout: number; 71 | }): Disposable & { 72 | promise: Promise

; 73 | } { 74 | let timeout: NodeJS.Timeout | null = null; 75 | const promise = new Promise

((resolve) => { 76 | timeout = setTimeout(() => { 77 | resolve(timeoutConfig.onTimeout()); 78 | }, timeoutConfig.timeout); 79 | void timeoutConfig.promise.then( 80 | (result) => { 81 | resolve(result); 82 | if (timeout) { 83 | clearTimeout(timeout); 84 | } 85 | }, 86 | (error) => { 87 | resolve(timeoutConfig.onError(error as Error)); 88 | if (timeout) { 89 | clearTimeout(timeout); 90 | } 91 | } 92 | ); 93 | }); 94 | return { 95 | dispose: () => (timeout ? clearTimeout(timeout) : void 0), 96 | promise, 97 | }; 98 | } 99 | 100 | export function toCheckablePromise(promise: Promise): { 101 | promise: Promise; 102 | done: boolean; 103 | } { 104 | let done = false; 105 | void promise.then(() => { 106 | done = true; 107 | }); 108 | return { 109 | promise, 110 | get done() { 111 | return done; 112 | }, 113 | }; 114 | } 115 | 116 | export async function pathExists(filePath: string): Promise { 117 | try { 118 | await fs.access(filePath, constants.R_OK); 119 | return true; 120 | } catch (e) { 121 | return false; 122 | } 123 | } 124 | 125 | async function tryReadFile(filePath: string): Promise { 126 | try { 127 | const contents = await fs.readFile(filePath, 'utf-8'); 128 | return contents; 129 | } catch (error) { 130 | return null; 131 | } 132 | } 133 | 134 | export async function tryReadJSON(filePath: string): Promise { 135 | const text = await tryReadFile(filePath); 136 | if (text === null) { 137 | return null; 138 | } 139 | return JSON.parse(text) as J; 140 | } 141 | 142 | export function basicHash(content: string): string { 143 | return crypto.createHash('md5').update(content).digest('hex'); 144 | } 145 | 146 | export function fromEntries( 147 | entries: Iterable 148 | ): Record { 149 | const result: Record = {}; 150 | for (const [key, value] of entries) { 151 | result[key] = value; 152 | } 153 | return result; 154 | } 155 | 156 | export async function getConfigFile( 157 | configFile: string, 158 | cwd: string | undefined, 159 | pathExistsFn: (filePath: string) => Promise = pathExists 160 | ): Promise { 161 | const absoluteConfigPaths = configFile 162 | ? configFile.split(',').map((c) => getAbsolutePath(c.trim(), cwd)) 163 | : []; 164 | for (const absoluteConfigPath of absoluteConfigPaths) { 165 | if (absoluteConfigPath && (await pathExistsFn(absoluteConfigPath))) { 166 | return absoluteConfigPath; 167 | } 168 | } 169 | 170 | return null; 171 | } 172 | 173 | function getAbsolutePath(filePath: string | null, cwd?: string): string | null { 174 | if (!filePath) { 175 | return null; 176 | } 177 | 178 | if (path.isAbsolute(filePath)) { 179 | return filePath; 180 | } 181 | if (!cwd) { 182 | return null; 183 | } 184 | return path.join(cwd, filePath); 185 | } 186 | 187 | export async function docker( 188 | args: ReadonlyArray, 189 | dockerEnv: Record | null, 190 | options: Omit< 191 | SpawnOptionsWithStdioTuple, 192 | 'stdio' 193 | > = {} 194 | ): Promise<{ 195 | success: boolean; 196 | code: number; 197 | stdout: string; 198 | stderr: string; 199 | err: Error | null; 200 | }> { 201 | const proc = spawn('docker', args, { 202 | ...options, 203 | env: { 204 | ...process.env, 205 | ...dockerEnv, 206 | }, 207 | stdio: ['ignore', 'pipe', 'pipe'], 208 | }); 209 | let stdout = ''; 210 | proc.stdout.on('data', (data: string | Buffer) => { 211 | stdout += data.toString(); 212 | }); 213 | let stderr = ''; 214 | proc.stderr.on('data', (data: string | Buffer) => { 215 | stderr += data.toString(); 216 | }); 217 | 218 | return new Promise((resolve) => { 219 | proc.once('error', (err) => { 220 | resolve({ 221 | success: false, 222 | code: 1, 223 | stdout: stdout, 224 | stderr: stderr, 225 | err, 226 | }); 227 | }); 228 | proc.once('exit', (code: number) => { 229 | resolve({ 230 | success: code === 0, 231 | code, 232 | stdout: stdout, 233 | stderr: stderr, 234 | err: null, 235 | }); 236 | }); 237 | }); 238 | } 239 | 240 | export function getPathMapper( 241 | pathMapping: Record, 242 | workspaceRoot?: string 243 | ): (filePath: string, inverse?: boolean) => string { 244 | return (filePath: string, inverse: boolean = false) => { 245 | if (Object.keys(pathMapping).length === 0) { 246 | return filePath; 247 | } 248 | const expandedFilePath = filePath.replace(/^~/, os.homedir()); 249 | // eslint-disable-next-line prefer-const 250 | for (let [fromPath, toPath] of Object.entries(pathMapping)) { 251 | if (!path.isAbsolute(fromPath) && workspaceRoot) { 252 | fromPath = path.join(workspaceRoot, fromPath); 253 | } 254 | 255 | const [from, to] = inverse 256 | ? [toPath, fromPath] 257 | : [fromPath, toPath]; 258 | const expandedFromPath = from.replace(/^~/, os.homedir()); 259 | if (expandedFilePath.startsWith(expandedFromPath)) { 260 | return expandedFilePath.replace( 261 | expandedFromPath, 262 | to.replace(/^~/, os.homedir()) 263 | ); 264 | } 265 | } 266 | return filePath; 267 | }; 268 | } 269 | -------------------------------------------------------------------------------- /shared/variables.ts: -------------------------------------------------------------------------------- 1 | import type { WorkspaceFolders } from '../server/src/lib/types'; 2 | import * as os from 'os'; 3 | 4 | export function replaceVariables( 5 | str: string, 6 | workspaceFolders: WorkspaceFolders | null 7 | ): string { 8 | return str.replace( 9 | /\${workspaceFolder(?::(\w+))?}/g, 10 | (_fullMatch, workspaceName: string | undefined) => { 11 | if (workspaceName) { 12 | if (!workspaceFolders) { 13 | throw new Error( 14 | 'workspaceFolder:name is not set but is used in a variable' 15 | ); 16 | } 17 | const folder = workspaceFolders.byName[workspaceName]; 18 | if (!folder) { 19 | throw new Error( 20 | `workspaceFolder:${workspaceName} is not set but is used in a variable` 21 | ); 22 | } 23 | return folder.fsPath; 24 | } 25 | 26 | if (!workspaceFolders?.default) { 27 | throw new Error( 28 | 'workspaceFolder is not set but is used in a variable' 29 | ); 30 | } 31 | return workspaceFolders.default.fsPath; 32 | } 33 | ); 34 | } 35 | 36 | export function replaceHomeDir(str: string): string { 37 | return str.replace(/^~/, os.homedir()); 38 | } 39 | -------------------------------------------------------------------------------- /static/images/phpstan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanderRonde/phpstan-vscode/298c7b748d6e1c3737248e244390a2e863376d58/static/images/phpstan.png -------------------------------------------------------------------------------- /test/demo.2/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "phpstan.singleFileMode": false, 3 | "phpstan.binPath": "vendor/bin/phpstan", 4 | "phpstan.binCommand": [], 5 | "phpstan.configFile": "phpstan.neon,phpstan.neon.dist,phpstan.dist.neon", 6 | "phpstan.paths": {}, 7 | "phpstan.dockerContainerName": "", 8 | "phpstan.rootDir": "", 9 | "phpstan.options": [], 10 | "phpstan.enableStatusBar": true, 11 | "phpstan.memoryLimit": "1G", 12 | "phpstan.enabled": true, 13 | "phpstan.projectTimeout": 300000, 14 | "phpstan.timeout": 300000, 15 | "phpstan.suppressTimeoutMessage": false, 16 | "phpstan.showProgress": false, 17 | "phpstan.showTypeOnHover": true, 18 | "phpstan.enableLanguageServer": false, 19 | "phpstan.ignoreErrors": [ 20 | "Xdebug: .*" 21 | ], 22 | "phpstan.suppressWorkspaceMessage": false, 23 | "phpstan.pro": false, 24 | "phpstan.tmpDir": "", 25 | "phpstan.checkValidity": false 26 | } 27 | -------------------------------------------------------------------------------- /test/demo.2/autoloader.php: -------------------------------------------------------------------------------- 1 | world($ha); 8 | } 9 | public function world(int $ho): void 10 | { 11 | } 12 | } -------------------------------------------------------------------------------- /test/demo.2/phpstan-with-rule.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 9 3 | scanDirectories: 4 | - . 5 | paths: 6 | - . 7 | tmpDir: %currentWorkingDirectory%/cache/phpstan 8 | 9 | rules: 10 | - TreeFetcher 11 | 12 | services: 13 | - 14 | class: PHPStanVSCodeDiagnoser 15 | tags: 16 | - phpstan.diagnoseExtension 17 | arguments: 18 | analysedPaths: %analysedPaths% 19 | currentWorkingDirectory: %currentWorkingDirectory% 20 | -------------------------------------------------------------------------------- /test/demo.2/phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | tmpDir: %currentWorkingDirectory%/cache/phpstan 3 | level: 10 4 | treatPhpDocTypesAsCertain: true 5 | editorUrl: 'vscode://file/%%file%%:%%line%%' 6 | editorUrlTitle: '%%relFile%%:%%line%%' 7 | paths: 8 | - php 9 | reportUnmatchedIgnoredErrors: false 10 | checkImplicitMixed: true 11 | reportMaybesInMethodSignatures: true 12 | reportMaybesInPropertyPhpDocTypes: true 13 | ignoreErrors: 14 | - '#Dynamic call to static method Symfony\\Component\\HttpFoundation\\Response::.*#' 15 | - '#Dynamic call to static method Illuminate\\Http\\Request::validate\(\).#' -------------------------------------------------------------------------------- /test/demo/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "phpstan.rootDir": "./", 3 | "phpstan.configFile": "phpstan.neon" 4 | } 5 | -------------------------------------------------------------------------------- /test/demo/autoloader.php: -------------------------------------------------------------------------------- 1 | void): void; 6 | public isList(): boolean; 7 | public values(): Neon[]; 8 | public keys(): string[]; 9 | public items(): { key: string; value: Neon }[]; 10 | public toObject(): Record; 11 | } 12 | type Neon = string | number | boolean | Map; 13 | export function decode(content: string): Neon; 14 | export function encode(content: Neon): string; 15 | } 16 | -------------------------------------------------------------------------------- /types/vscode.proposed.portsAttributes.d.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | declare module 'vscode' { 7 | // https://github.com/microsoft/vscode/issues/115616 @alexr00 8 | 9 | /** 10 | * The action that should be taken when a port is discovered through automatic port forwarding discovery. 11 | */ 12 | export enum PortAutoForwardAction { 13 | /** 14 | * Notify the user that the port is being forwarded. This is the default action. 15 | */ 16 | Notify = 1, 17 | /** 18 | * Once the port is forwarded, open the user's web browser to the forwarded port. 19 | */ 20 | OpenBrowser = 2, 21 | /** 22 | * Once the port is forwarded, open the preview browser to the forwarded port. 23 | */ 24 | OpenPreview = 3, 25 | /** 26 | * Forward the port silently. 27 | */ 28 | Silent = 4, 29 | /** 30 | * Do not forward the port. 31 | */ 32 | Ignore = 5, 33 | } 34 | 35 | /** 36 | * The attributes that a forwarded port can have. 37 | */ 38 | export class PortAttributes { 39 | /** 40 | * The action to be taken when this port is detected for auto forwarding. 41 | */ 42 | public autoForwardAction: PortAutoForwardAction; 43 | 44 | /** 45 | * Creates a new PortAttributes object 46 | * @param port the port number 47 | * @param autoForwardAction the action to take when this port is detected 48 | */ 49 | public constructor(autoForwardAction: PortAutoForwardAction); 50 | } 51 | 52 | /** 53 | * A provider of port attributes. Port attributes are used to determine what action should be taken when a port is discovered. 54 | */ 55 | export interface PortAttributesProvider { 56 | /** 57 | * Provides attributes for the given port. For ports that your extension doesn't know about, simply 58 | * return undefined. For example, if `providePortAttributes` is called with ports 3000 but your 59 | * extension doesn't know anything about 3000 you should return undefined. 60 | * @param port The port number of the port that attributes are being requested for. 61 | * @param pid The pid of the process that is listening on the port. If the pid is unknown, undefined will be passed. 62 | * @param commandLine The command line of the process that is listening on the port. If the command line is unknown, undefined will be passed. 63 | * @param token A cancellation token that indicates the result is no longer needed. 64 | */ 65 | providePortAttributes( 66 | attributes: { port: number; pid?: number; commandLine?: string }, 67 | token: CancellationToken 68 | ): ProviderResult; 69 | } 70 | 71 | /** 72 | * A selector that will be used to filter which {@link PortAttributesProvider} should be called for each port. 73 | */ 74 | export interface PortAttributesSelector { 75 | /** 76 | * Specifying a port range will cause your provider to only be called for ports within the range. 77 | * The start is inclusive and the end is exclusive. 78 | */ 79 | portRange?: [number, number] | number; 80 | 81 | /** 82 | * Specifying a command pattern will cause your provider to only be called for processes whose command line matches the pattern. 83 | */ 84 | commandPattern?: RegExp; 85 | } 86 | 87 | export namespace workspace { 88 | /** 89 | * If your extension listens on ports, consider registering a PortAttributesProvider to provide information 90 | * about the ports. For example, a debug extension may know about debug ports in it's debuggee. By providing 91 | * this information with a PortAttributesProvider the extension can tell the editor that these ports should be 92 | * ignored, since they don't need to be user facing. 93 | * 94 | * The results of the PortAttributesProvider are merged with the user setting `remote.portsAttributes`. If the values conflict, the user setting takes precedence. 95 | * 96 | * @param portSelector It is best practice to specify a port selector to avoid unnecessary calls to your provider. 97 | * If you don't specify a port selector your provider will be called for every port, which will result in slower port forwarding for the user. 98 | * @param provider The {@link PortAttributesProvider PortAttributesProvider}. 99 | */ 100 | export function registerPortAttributesProvider( 101 | portSelector: PortAttributesSelector, 102 | provider: PortAttributesProvider 103 | ): Disposable; 104 | } 105 | } 106 | --------------------------------------------------------------------------------