├── .env.example ├── .eslintrc.json ├── .github └── ISSUE_TEMPLATE │ ├── bug.yml │ └── feature-request.yml ├── .gitignore ├── .vscode-test.mjs ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── SUPPORT.md ├── blade.configuration.json ├── esbuild.js ├── generatable.json ├── generate-config.php ├── generate-templates.php ├── icon.png ├── package-lock.json ├── package.json ├── php-templates ├── app.php ├── auth.php ├── blade-components.php ├── blade-directives.php ├── bootstrap-laravel.php ├── configs.php ├── inertia.php ├── middleware.php ├── models.php ├── routes.php ├── translations.php └── views.php ├── precheck ├── src ├── blade │ ├── BladeFormatter.ts │ ├── BladeFormattingEditProvider.ts │ ├── bladeSpacer.ts │ └── client.ts ├── codeAction │ ├── codeActionProvider.ts │ └── support.ts ├── commands │ └── index.ts ├── completion │ ├── Blade.ts │ ├── CompletionProvider.ts │ ├── Eloquent.ts │ ├── Registry.ts │ └── Validation.ts ├── diagnostic │ ├── diagnostic.ts │ └── index.ts ├── downloaders │ ├── FileDownloader.ts │ ├── IFileDownloader.ts │ ├── IGitHubRelease.ts │ ├── logging │ │ ├── ILogger.ts │ │ └── OutputLogger.ts │ ├── networking │ │ ├── HttpRequestHandler.ts │ │ └── IHttpRequestHandler.ts │ └── utility │ │ ├── Errors.ts │ │ ├── RetryUtility.ts │ │ └── Stream.ts ├── extension.ts ├── features │ ├── appBinding.ts │ ├── asset.ts │ ├── auth.ts │ ├── bladeComponent.ts │ ├── config.ts │ ├── controllerAction.ts │ ├── env.ts │ ├── inertia.ts │ ├── livewireComponent.ts │ ├── middleware.ts │ ├── mix.ts │ ├── paths.ts │ ├── route.ts │ ├── storage.ts │ ├── translation.ts │ └── view.ts ├── hover │ └── HoverProvider.ts ├── index.d.ts ├── link │ └── LinkProvider.ts ├── parser │ └── AutocompleteResult.ts ├── repositories │ ├── appBinding.ts │ ├── asset.ts │ ├── auth.ts │ ├── bladeComponents.ts │ ├── configs.ts │ ├── controllers.ts │ ├── customBladeDirectives.ts │ ├── env-example.ts │ ├── env.ts │ ├── index.ts │ ├── inertia.ts │ ├── middleware.ts │ ├── mix.ts │ ├── models.ts │ ├── paths.ts │ ├── routes.ts │ ├── translations.ts │ └── views.ts ├── support │ ├── config.ts │ ├── debug.ts │ ├── doc.ts │ ├── docblocks.ts │ ├── fileWatcher.ts │ ├── generated-config.ts │ ├── logger.ts │ ├── parser.ts │ ├── patterns.ts │ ├── php.ts │ ├── popup.ts │ ├── project.ts │ └── util.ts ├── syntax │ └── DocumentHighlight.ts ├── templates │ ├── app.ts │ ├── auth.ts │ ├── blade-components.ts │ ├── blade-directives.ts │ ├── bootstrap-laravel.ts │ ├── configs.ts │ ├── index.ts │ ├── inertia.ts │ ├── middleware.ts │ ├── models.ts │ ├── routes.ts │ ├── translations.ts │ └── views.ts ├── test-runner │ ├── docker-phpunit-command.ts │ ├── index.ts │ ├── phpunit-command.ts │ ├── remote-phpunit-command.ts │ └── test-controller.ts └── types.ts ├── syntaxes └── blade.tmLanguage.json ├── tsconfig.json └── vsc-extension-quickstart.md /.env.example: -------------------------------------------------------------------------------- 1 | # Local path to the PHP Parser binary 2 | PHP_PARSER_BINARY_PATH= 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module" 7 | }, 8 | "plugins": [ 9 | "@typescript-eslint" 10 | ], 11 | "rules": { 12 | "@typescript-eslint/naming-convention": [ 13 | "warn", 14 | { 15 | "selector": "import", 16 | "format": [ "camelCase", "PascalCase" ] 17 | } 18 | ], 19 | "@typescript-eslint/semi": "warn", 20 | "curly": "warn", 21 | "eqeqeq": "warn", 22 | "no-throw-literal": "warn", 23 | "semi": "off" 24 | }, 25 | "ignorePatterns": [ 26 | "out", 27 | "dist", 28 | "**/*.d.ts" 29 | ] 30 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report. 3 | title: "[Bug]: " 4 | labels: ["bug"] 5 | assignees: 6 | - joetannenbaum 7 | body: 8 | - type: input 9 | id: extension_version 10 | attributes: 11 | label: Extension Version 12 | validations: 13 | required: true 14 | - type: dropdown 15 | id: php_binary 16 | attributes: 17 | label: PHP Binary 18 | description: How are you running PHP on your machine? 19 | options: 20 | - Herd 21 | - Valet 22 | - Sail 23 | - Docker 24 | - Local PHP 25 | - Other 26 | default: 0 27 | validations: 28 | required: true 29 | - type: dropdown 30 | id: operating_system 31 | attributes: 32 | label: Operating System 33 | description: What operating system are you using? 34 | options: 35 | - macOS 36 | - Windows 37 | - Linux 38 | - Other 39 | default: 0 40 | validations: 41 | required: true 42 | - type: textarea 43 | id: what-happened 44 | attributes: 45 | label: What happened? 46 | description: Also tell us, what did you expect to happen? 47 | validations: 48 | required: true 49 | - type: textarea 50 | id: code_sample 51 | attributes: 52 | label: Mimimal Code Sample 53 | description: If you can, please provide a minimal code sample that reproduces the bug. 54 | render: php 55 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest a new feature or enhancement. 3 | title: "[Feature]: " 4 | labels: ["enhancement"] 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Feature Description 10 | description: Please describe the feature you would like to see added. 11 | validations: 12 | required: true 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | node_modules 4 | .vscode-test/ 5 | *.vsix 6 | .DS_Store 7 | .env 8 | -------------------------------------------------------------------------------- /.vscode-test.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@vscode/test-cli"; 2 | 3 | export default defineConfig([ 4 | { 5 | label: "unitTests", 6 | files: "out/test/**/*.test.js", 7 | version: "insiders", 8 | workspaceFolder: "./sampleWorkspace", 9 | mocha: { 10 | ui: "tdd", 11 | timeout: 20000, 12 | }, 13 | }, 14 | // you can specify additional test configurations, too 15 | ]); 16 | 17 | // export default defineConfig({ 18 | // files: 'out/test/**/*.test.js', 19 | // }); 20 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint", 6 | "ms-vscode.extension-test-runner", 7 | "connor4312.esbuild-problem-matchers" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | 6 | { 7 | "version": "0.2.0", 8 | "configurations": [ 9 | { 10 | "name": "Run Extension", 11 | "type": "extensionHost", 12 | "request": "launch", 13 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"], 14 | "outFiles": ["${workspaceFolder}/dist/**/*.js"], 15 | "sourceMaps": true, 16 | "resolveSourceMapLocations": [ 17 | "${workspaceFolder}/dist/**/*.js", 18 | "${workspaceFolder}/**/*.ts" 19 | ], 20 | "preLaunchTask": "${defaultBuildTask}", 21 | "envFile": "${workspaceFolder}/.env" 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 10 | "typescript.tsc.autoDetect": "off" 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "watch", 6 | "dependsOn": ["npm: watch:tsc", "npm: watch:esbuild"], 7 | "presentation": { 8 | "reveal": "never" 9 | }, 10 | "group": { 11 | "kind": "build", 12 | "isDefault": true 13 | } 14 | }, 15 | { 16 | "type": "npm", 17 | "script": "watch:esbuild", 18 | "group": "build", 19 | "problemMatcher": "$esbuild-watch", 20 | "isBackground": true, 21 | "label": "npm: watch:esbuild", 22 | "presentation": { 23 | "group": "watch", 24 | "reveal": "never" 25 | } 26 | }, 27 | { 28 | "type": "npm", 29 | "script": "watch:tsc", 30 | "group": "build", 31 | "problemMatcher": "$tsc-watch", 32 | "isBackground": true, 33 | "label": "npm: watch:tsc", 34 | "presentation": { 35 | "group": "watch", 36 | "reveal": "never" 37 | } 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .github/** 2 | .vscode/** 3 | .vscode-test/** 4 | src/** 5 | .gitignore 6 | .yarnrc 7 | vsc-extension-quickstart.md 8 | **/tsconfig.json 9 | **/.eslintrc.json 10 | **/*.map 11 | **/*.ts 12 | **/.vscode-test.* 13 | node_modules 14 | server/node_modules 15 | out 16 | webpack.config.js 17 | esbuild.js 18 | *.php 19 | generatable.json 20 | php-templates 21 | precheck 22 | .env 23 | .env.example 24 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | To get the extension running locally: 4 | 5 | ``` 6 | git clone https://github.com/laravel/vs-code-extension.git 7 | cd vs-code-extension 8 | npm install 9 | ``` 10 | 11 | - Open the project in VS Code 12 | - Open the command palette and search for "Debug: Start Debugging" 13 | - A new VS Code window will open, you'll see "[Extension Development Host]" in the title 14 | - Open any folder you'd like to test against 15 | - Press `⌘ + R` to reload the Extension Development Host project and see changes 16 | 17 | ## Of Note 18 | 19 | - `console.log` will appear in your main VS Code "Debug Console" tab, _not_ the Extension Development Host window 20 | - `info`, `error`, etc from `src/support/logger.ts` will show up in the "Output" tab (make sure to select "Laravel" from the list) of your Extension Development Host window 21 | 22 | ## Testing the Parser 23 | 24 | The [PHP parser](https://github.com/laravel/vs-code-php-parser-cli) is a standalone binary that parses the PHP scripts for item detection and autocomplete. 25 | 26 | If you are making changes to the parser, follow the setup instructions from that repo and create an `.env` file at the root of the `vs-code-extension` with the full path to this variable set: 27 | 28 | `PHP_PARSER_BINARY_PATH=[FULL PATH TO DIRECTORY]/vs-code-php-parser-cli/php-parser` 29 | 30 | If you make changes to your `.env` file, you'll have to close the Extension Development Host and start debugging again. 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Taylor Otwell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Official Laravel VS Code Extension 2 | 3 | Below you'll find a list of features as well as a roadmap with features we will be integrating soon. 4 | 5 | **Please Note:** 6 | 7 | - This extension will occasionally boot your app in the background to collect information about your app for use in autocompletion, linking, hovering, and diagnostics 8 | - When you first install the extension it will download a small binary to your machine, we use this binary for PHP parsing 9 | - This extension is intended to provide Laravel-specific intelligence, not general PHP intelligence. If you are currently using an extension for general PHP intelligence, it is recommended to continue using that in conjunction with this extension. 10 | 11 | ## Supported Versions 12 | 13 | The extension supports all Laravel versions currently listed under the [Support Policy](https://laravel.com/docs/releases#support-policy) and requires PHP >= 8.0 to run. 14 | 15 | ## Features 16 | 17 | A non-exhaustive list of features covered in the extension: 18 | 19 | ### App Bindings 20 | 21 | ```php 22 | app('auth') 23 | App::make('auth.driver') 24 | app()->make('auth.driver') 25 | App::bound('auth.driver') 26 | App::isShared('auth.driver') 27 | // etc 28 | ``` 29 | 30 | - Auto-completion 31 | - Links directly to binding 32 | - Warns when binding not found 33 | - Hoverable 34 | 35 | ### Assets 36 | 37 | ```php 38 | asset('my-amazing-jpeg.png') 39 | ``` 40 | 41 | - Auto-completion 42 | - Links directly to asset 43 | - Warns when asset not found 44 | 45 | 58 | 59 | ### Blade 60 | 61 | - Syntax highlighting 62 | 63 | ### Config 64 | 65 | ```php 66 | config('broadcasting.connections.reverb.app_id'); 67 | Config::get('broadcasting.connections.reverb.app_id'); 68 | Config::getMany([ 69 | 'broadcasting.connections.reverb.app_id', 70 | 'broadcasting.connections.reverb.driver', 71 | ]); 72 | config()->string('broadcasting.connections.reverb.app_id'); 73 | // etc 74 | ``` 75 | 76 | - Auto-completion 77 | - Links directly to config value 78 | - Warns when config not found 79 | - Hoverable 80 | 81 | ### Eloquent 82 | 83 | - Method auto-completion 84 | - Field auto-completion (e.g. `where` methods, `create`/`make`/object creation) 85 | - Relationship auto-completion (e.g. `with` method + `with` with array keys) 86 | - Sub-query auto-completion ( `with` with array keys + value as closure) 87 | 88 | ### Env 89 | 90 | ```php 91 | env('REVERB_APP_ID'); 92 | Env::get('REVERB_APP_ID'); 93 | ``` 94 | 95 | - Auto-completion 96 | - Links directly to env value 97 | - Warns when env not found, offers quick fixes: 98 | - Add to `.env` 99 | - Copy value from `.env.example` 100 | - Hoverable 101 | 102 | ### Inertia 103 | 104 | ```php 105 | inertia('Pages/Dashboard'); 106 | Inertia::render('Pages/Dashboard'); 107 | Route::inertia('/dashboard', 'Pages/Dashboard'); 108 | ``` 109 | 110 | - Auto-completion 111 | - Links directly to JS view 112 | - Warns when view not found, offers quick fixes: 113 | - Create view 114 | - Hoverable 115 | 116 | **Note:** If the extension is unable to find your Inertia views, you may need to update your `inertia.testing.page_paths` and/or `inertia.testing.page_extensions` config values. 117 | 118 | ### Route 119 | 120 | ```php 121 | route('dashboard'); 122 | signedRoute('dashboard'); 123 | Redirect::route('dashboard'); 124 | Redirect::signedRoute('dashboard'); 125 | URL::route('dashboard'); 126 | URL::signedRoute('dashboard'); 127 | Route::middleware('auth'); 128 | redirect()->route('dashboard'); 129 | // etc 130 | ``` 131 | 132 | - Auto-completion 133 | - Links directly to route definition 134 | - Warns when route not found 135 | - Hoverable 136 | 137 | ### Middleware 138 | 139 | ```php 140 | Route::middleware('auth'); 141 | Route::middleware(['auth', 'web']); 142 | Route::withoutMiddleware('auth'); 143 | // etc 144 | ``` 145 | 146 | - Auto-completion 147 | - Links directly to middleware handling 148 | - Warns when middleware not found 149 | - Hoverable 150 | 151 | ### Translation 152 | 153 | ```php 154 | trans('auth.failed'); 155 | __('auth.failed'); 156 | Lang::has('auth.failed'); 157 | Lang::get('auth.failed'); 158 | // etc 159 | ``` 160 | 161 | - Auto-completion 162 | - Links directly to translation 163 | - Warns when translation not found 164 | - Hoverable 165 | - Parameter auto-completion 166 | 167 | ### Validation 168 | 169 | ```php 170 | Validator::validate($input, ['name' => 'required']); 171 | request()->validate(['name' => 'required']); 172 | request()->sometimes(['name' => 'required']); 173 | // etc 174 | ``` 175 | 176 | - Auto-completion for strings/arrays (not "|" just yet) 177 | 178 | ### View 179 | 180 | ```php 181 | view('dashboard'); 182 | Route::view('/', 'home'); 183 | ``` 184 | 185 | - Auto-completion 186 | - Links directly to Blade view 187 | - Warns when view not found, offers quick fixes: 188 | - Create view 189 | - Hoverable 190 | 191 | ## On the Roadmap 192 | 193 | - Integration with VS Code test runner 194 | - Livewire support 195 | - Volt support 196 | - Pint support 197 | - Better autocompletion, linking, hovering, and diagnostics in Blade files 198 | 199 | ### LSP Availability 200 | 201 | Our focus right now is to create the best VS Code experience for Laravel developers. While we're not ruling out the possibility of porting much of this functionality to an LSP in the future, it's not on the immediate roadmap. 202 | -------------------------------------------------------------------------------- /SUPPORT.md: -------------------------------------------------------------------------------- 1 | For bug reports and feature requests, please use GitHub issues: 2 | 3 | [https://github.com/laravel/vs-code-extension/issues](https://github.com/laravel/vs-code-extension/issues) 4 | -------------------------------------------------------------------------------- /blade.configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "comments": { 3 | "blockComment": [ "{{--", "--}}" ] 4 | }, 5 | "brackets": [ 6 | ["{", "}"], 7 | ["[", "]"], 8 | ["(", ")"] 9 | ], 10 | "autoClosingPairs": [ 11 | ["{", "}"], 12 | ["[", "]"], 13 | ["(", ")"], 14 | ["\"", "\""], 15 | ["'", "'"] 16 | ], 17 | "surroundingPairs": [ 18 | ["{", "}"], 19 | ["[", "]"], 20 | ["(", ")"], 21 | ["\"", "\""], 22 | ["'", "'"] 23 | ] 24 | } -------------------------------------------------------------------------------- /esbuild.js: -------------------------------------------------------------------------------- 1 | const esbuild = require("esbuild"); 2 | 3 | const production = process.argv.includes("--production"); 4 | const watch = process.argv.includes("--watch"); 5 | 6 | async function main() { 7 | const ctx = await esbuild.context({ 8 | entryPoints: ["src/extension.ts"], 9 | bundle: true, 10 | format: "cjs", 11 | minify: production, 12 | sourcemap: !production, 13 | sourcesContent: false, 14 | platform: "node", 15 | outfile: "dist/extension.js", 16 | external: ["vscode"], 17 | logLevel: "silent", 18 | plugins: [ 19 | /* add to the end of plugins array */ 20 | esbuildProblemMatcherPlugin, 21 | ], 22 | }); 23 | 24 | if (watch) { 25 | await ctx.watch(); 26 | } else { 27 | await ctx.rebuild(); 28 | await ctx.dispose(); 29 | } 30 | } 31 | 32 | /** 33 | * @type {import('esbuild').Plugin} 34 | */ 35 | const esbuildProblemMatcherPlugin = { 36 | name: "esbuild-problem-matcher", 37 | 38 | setup(build) { 39 | build.onStart(() => { 40 | console.log("[watch] build started"); 41 | }); 42 | build.onEnd((result) => { 43 | result.errors.forEach(({ text, location }) => { 44 | console.error(`✘ [ERROR] ${text}`); 45 | console.error( 46 | ` ${location.file}:${location.line}:${location.column}:`, 47 | ); 48 | }); 49 | console.log("[watch] build finished"); 50 | }); 51 | }, 52 | }; 53 | 54 | main().catch((e) => { 55 | console.error(e); 56 | process.exit(1); 57 | }); 58 | -------------------------------------------------------------------------------- /generatable.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "type": "appBinding", 4 | "label": "app bindings" 5 | }, 6 | { 7 | "type": "asset" 8 | }, 9 | { 10 | "type": "auth" 11 | }, 12 | { 13 | "type": "config" 14 | }, 15 | { 16 | "type": "controllerAction", 17 | "label": "controller actions" 18 | }, 19 | { 20 | "type": "env" 21 | }, 22 | { 23 | "type": "inertia", 24 | "label": "Inertia" 25 | }, 26 | { 27 | "type": "middleware" 28 | }, 29 | { 30 | "type": "mix" 31 | }, 32 | { 33 | "type": "route" 34 | }, 35 | { 36 | "type": "translation" 37 | }, 38 | { 39 | "type": "view" 40 | }, 41 | { 42 | "type": "paths", 43 | "features": [ 44 | "link" 45 | ] 46 | }, 47 | { 48 | "type": "bladeComponent", 49 | "label": "Blade components", 50 | "features": [ 51 | "link", 52 | "completion", 53 | "hover" 54 | ] 55 | }, 56 | { 57 | "type": "livewireComponent", 58 | "label": "Livewire components", 59 | "features": [ 60 | "link", 61 | "completion" 62 | ] 63 | }, 64 | { 65 | "type": "storage", 66 | "features": [ 67 | "link", 68 | "completion", 69 | "diagnostics" 70 | ] 71 | } 72 | ] 73 | -------------------------------------------------------------------------------- /generate-config.php: -------------------------------------------------------------------------------- 1 | $b['type']; 10 | }); 11 | 12 | foreach ($items as $item) { 13 | $type = $item['type']; 14 | $label = $item['label'] ?? $type; 15 | $features = $item['features'] ?? ['diagnostics', 'hover', 'link', 'completion']; 16 | 17 | foreach ($features as $feature) { 18 | $config["Laravel.{$type}.{$feature}"] = [ 19 | 'type' => 'boolean', 20 | 'default' => true, 21 | 'generated' => true, 22 | 'description' => match($feature) { 23 | 'diagnostics' => "Enable diagnostics for {$label}.", 24 | 'hover' => "Enable hover information for {$label}.", 25 | 'link' => "Enable linking for {$label}.", 26 | 'completion' => "Enable completion for {$label}.", 27 | default => null, 28 | }, 29 | ]; 30 | } 31 | } 32 | 33 | $currentConfig = $packageJson['contributes']['configuration']['properties'] ?? []; 34 | 35 | $customConfig = array_filter($currentConfig, function($value, $key) { 36 | return ($value['generated'] ?? false) === false; 37 | }, ARRAY_FILTER_USE_BOTH); 38 | 39 | $packageJson['contributes']['configuration']['properties'] = array_merge($customConfig, $config); 40 | 41 | file_put_contents( 42 | __DIR__ . '/package.json', 43 | json_encode($packageJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL 44 | ); 45 | 46 | $keys = array_map(function($key) { 47 | return str_replace('Laravel.', '', $key); 48 | }, array_keys($config)); 49 | 50 | file_put_contents( 51 | __DIR__ . '/src/support/generated-config.ts', 52 | "export type GeneratedConfigKey = '" . implode("' | '", $keys) . "';" . PHP_EOL 53 | ); 54 | -------------------------------------------------------------------------------- /generate-templates.php: -------------------------------------------------------------------------------- 1 | getBindings()) 4 | ->filter(fn ($binding) => ($binding['concrete'] ?? null) !== null) 5 | ->flatMap(function ($binding, $key) { 6 | $boundTo = new ReflectionFunction($binding['concrete']); 7 | 8 | $closureClass = $boundTo->getClosureScopeClass(); 9 | 10 | if ($closureClass === null) { 11 | return []; 12 | } 13 | 14 | return [ 15 | $key => [ 16 | 'path' => LaravelVsCode::relativePath($closureClass->getFileName()), 17 | 'class' => $closureClass->getName(), 18 | 'line' => $boundTo->getStartLine(), 19 | ], 20 | ]; 21 | })->toJson(); 22 | -------------------------------------------------------------------------------- /php-templates/auth.php: -------------------------------------------------------------------------------- 1 | each(fn($file) => include_once($file)); 4 | 5 | $modelPolicies = collect(get_declared_classes()) 6 | ->filter(fn($class) => is_subclass_of($class, \Illuminate\Database\Eloquent\Model::class)) 7 | ->filter(fn($class) => !in_array($class, [ 8 | \Illuminate\Database\Eloquent\Relations\Pivot::class, 9 | \Illuminate\Foundation\Auth\User::class, 10 | ])) 11 | ->flatMap(fn($class) => [ 12 | $class => \Illuminate\Support\Facades\Gate::getPolicyFor($class), 13 | ]) 14 | ->filter(fn($policy) => $policy !== null); 15 | 16 | function vsCodeGetAuthenticatable() { 17 | try { 18 | $guard = auth()->guard(); 19 | 20 | $reflection = new \ReflectionClass($guard); 21 | 22 | if (!$reflection->hasProperty("provider")) { 23 | return null; 24 | } 25 | 26 | $property = $reflection->getProperty("provider"); 27 | $provider = $property->getValue($guard); 28 | 29 | if ($provider instanceof \Illuminate\Auth\EloquentUserProvider) { 30 | $providerReflection = new \ReflectionClass($provider); 31 | $modelProperty = $providerReflection->getProperty("model"); 32 | 33 | return str($modelProperty->getValue($provider))->prepend("\\")->toString(); 34 | } 35 | 36 | if ($provider instanceof \Illuminate\Auth\DatabaseUserProvider) { 37 | return str(\Illuminate\Auth\GenericUser::class)->prepend("\\")->toString(); 38 | } 39 | } catch (\Exception | \Throwable $e) { 40 | return null; 41 | } 42 | 43 | return null; 44 | } 45 | 46 | function vsCodeGetPolicyInfo($policy, $model) 47 | { 48 | $methods = (new ReflectionClass($policy))->getMethods(); 49 | 50 | return collect($methods)->map(fn(ReflectionMethod $method) => [ 51 | 'key' => $method->getName(), 52 | 'uri' => $method->getFileName(), 53 | 'policy' => is_string($policy) ? $policy : get_class($policy), 54 | 'model' => $model, 55 | 'line' => $method->getStartLine(), 56 | ])->filter(fn($ability) => !in_array($ability['key'], ['allow', 'deny'])); 57 | } 58 | 59 | echo json_encode([ 60 | 'authenticatable' => vsCodeGetAuthenticatable(), 61 | 'policies' => collect(\Illuminate\Support\Facades\Gate::abilities()) 62 | ->map(function ($policy, $key) { 63 | $reflection = new \ReflectionFunction($policy); 64 | $policyClass = null; 65 | $closureThis = $reflection->getClosureThis(); 66 | 67 | if ($closureThis !== null) { 68 | if (get_class($closureThis) === \Illuminate\Auth\Access\Gate::class) { 69 | $vars = $reflection->getClosureUsedVariables(); 70 | 71 | if (isset($vars['callback'])) { 72 | [$policyClass, $method] = explode('@', $vars['callback']); 73 | 74 | $reflection = new \ReflectionMethod($policyClass, $method); 75 | } 76 | } 77 | } 78 | 79 | return [ 80 | 'key' => $key, 81 | 'uri' => $reflection->getFileName(), 82 | 'policy' => $policyClass, 83 | 'line' => $reflection->getStartLine(), 84 | ]; 85 | }) 86 | ->merge( 87 | collect(\Illuminate\Support\Facades\Gate::policies())->flatMap(fn($policy, $model) => vsCodeGetPolicyInfo($policy, $model)), 88 | ) 89 | ->merge( 90 | $modelPolicies->flatMap(fn($policy, $model) => vsCodeGetPolicyInfo($policy, $model)), 91 | ) 92 | ->values() 93 | ->groupBy('key') 94 | ->map(fn($item) => $item->map(fn($i) => \Illuminate\Support\Arr::except($i, 'key'))), 95 | ]); 96 | -------------------------------------------------------------------------------- /php-templates/blade-directives.php: -------------------------------------------------------------------------------- 1 | getCustomDirectives()) 4 | ->map(function ($customDirective, $name) { 5 | if ($customDirective instanceof \Closure) { 6 | return [ 7 | 'name' => $name, 8 | 'hasParams' => (new ReflectionFunction($customDirective))->getNumberOfParameters() >= 1, 9 | ]; 10 | } 11 | 12 | if (is_array($customDirective)) { 13 | return [ 14 | 'name' => $name, 15 | 'hasParams' => (new ReflectionMethod($customDirective[0], $customDirective[1]))->getNumberOfParameters() >= 1, 16 | ]; 17 | } 18 | 19 | return null; 20 | }) 21 | ->filter() 22 | ->values() 23 | ->toJson(); 24 | -------------------------------------------------------------------------------- /php-templates/bootstrap-laravel.php: -------------------------------------------------------------------------------- 1 | getMessage()); 33 | } 34 | } 35 | 36 | try { 37 | $app = require_once __DIR__ . '/../../bootstrap/app.php'; 38 | } catch (\Throwable $e) { 39 | LaravelVsCode::startupError($e); 40 | exit(1); 41 | } 42 | 43 | $app->register(new class($app) extends \Illuminate\Support\ServiceProvider 44 | { 45 | public function boot() 46 | { 47 | config([ 48 | 'logging.channels.null' => [ 49 | 'driver' => 'monolog', 50 | 'handler' => \Monolog\Handler\NullHandler::class, 51 | ], 52 | 'logging.default' => 'null', 53 | ]); 54 | } 55 | }); 56 | 57 | try { 58 | $kernel = $app->make(Illuminate\Contracts\Console\Kernel::class); 59 | $kernel->bootstrap(); 60 | } catch (\Throwable $e) { 61 | LaravelVsCode::startupError($e); 62 | exit(1); 63 | } 64 | 65 | echo LaravelVsCode::outputMarker('START_OUTPUT'); 66 | __VSCODE_LARAVEL_OUTPUT__; 67 | echo LaravelVsCode::outputMarker('END_OUTPUT'); 68 | 69 | exit(0); 70 | -------------------------------------------------------------------------------- /php-templates/configs.php: -------------------------------------------------------------------------------- 1 | merge(glob(config_path("**/*.php"))) 5 | ->map(fn ($path) => [ 6 | (string) str($path) 7 | ->replace([config_path('/'), ".php"], "") 8 | ->replace(DIRECTORY_SEPARATOR, "."), 9 | $path 10 | ]); 11 | 12 | $vendor = collect(glob(base_path("vendor/**/**/config/*.php")))->map(fn ( 13 | $path 14 | ) => [ 15 | (string) str($path) 16 | ->afterLast(DIRECTORY_SEPARATOR . "config" . DIRECTORY_SEPARATOR) 17 | ->replace(".php", "") 18 | ->replace(DIRECTORY_SEPARATOR, "."), 19 | $path 20 | ]); 21 | 22 | $configPaths = $local 23 | ->merge($vendor) 24 | ->groupBy(0) 25 | ->map(fn ($items)=>$items->pluck(1)); 26 | 27 | $cachedContents = []; 28 | $cachedParsed = []; 29 | 30 | function vsCodeGetConfigValue($value, $key, $configPaths) { 31 | $parts = explode(".", $key); 32 | $toFind = $key; 33 | $found = null; 34 | 35 | while (count($parts) > 0) { 36 | $toFind = implode(".", $parts); 37 | 38 | if ($configPaths->has($toFind)) { 39 | $found = $toFind; 40 | break; 41 | } 42 | 43 | array_pop($parts); 44 | } 45 | 46 | if ($found === null) { 47 | return null; 48 | } 49 | 50 | $file = null; 51 | $line = null; 52 | 53 | if ($found === $key) { 54 | $file = $configPaths->get($found)[0]; 55 | } else { 56 | foreach ($configPaths->get($found) as $path) { 57 | $cachedContents[$path] ??= file_get_contents($path); 58 | $cachedParsed[$path] ??= token_get_all($cachedContents[$path]); 59 | 60 | $keysToFind = str($key) 61 | ->replaceFirst($found, "") 62 | ->ltrim(".") 63 | ->explode("."); 64 | 65 | if (is_numeric($keysToFind->last())) { 66 | $index = $keysToFind->pop(); 67 | 68 | if ($index !== "0") { 69 | return null; 70 | } 71 | 72 | $key = collect(explode(".", $key)); 73 | $key->pop(); 74 | $key = $key->implode("."); 75 | $value = "array(...)"; 76 | } 77 | 78 | $nextKey = $keysToFind->shift(); 79 | $expectedDepth = 1; 80 | 81 | $depth = 0; 82 | 83 | foreach ($cachedParsed[$path] as $token) { 84 | if ($token === "[") { 85 | $depth++; 86 | } 87 | 88 | if ($token === "]") { 89 | $depth--; 90 | } 91 | 92 | if (!is_array($token)) { 93 | continue; 94 | } 95 | 96 | $str = trim($token[1], '"\''); 97 | 98 | if ( 99 | $str === $nextKey && 100 | $depth === $expectedDepth && 101 | $token[0] === T_CONSTANT_ENCAPSED_STRING 102 | ) { 103 | $nextKey = $keysToFind->shift(); 104 | $expectedDepth++; 105 | 106 | if ($nextKey === null) { 107 | $file = $path; 108 | $line = $token[2]; 109 | break; 110 | } 111 | } 112 | } 113 | 114 | if ($file) { 115 | break; 116 | } 117 | } 118 | } 119 | 120 | return [ 121 | "name" => $key, 122 | "value" => $value, 123 | "file" => $file === null ? null : str_replace(base_path(DIRECTORY_SEPARATOR), '', $file), 124 | "line" => $line 125 | ]; 126 | } 127 | 128 | function vsCodeUnpackDottedKey($value, $key) { 129 | $arr = [$key => $value]; 130 | $parts = explode('.', $key); 131 | array_pop($parts); 132 | 133 | while (count($parts)) { 134 | $arr[implode('.', $parts)] = 'array(...)'; 135 | array_pop($parts); 136 | } 137 | 138 | return $arr; 139 | } 140 | 141 | echo collect(\Illuminate\Support\Arr::dot(config()->all())) 142 | ->mapWithKeys(fn($value, $key) => vsCodeUnpackDottedKey($value, $key)) 143 | ->map(fn ($value, $key) => vsCodeGetConfigValue($value, $key, $configPaths)) 144 | ->filter() 145 | ->values() 146 | ->toJson(); 147 | -------------------------------------------------------------------------------- /php-templates/inertia.php: -------------------------------------------------------------------------------- 1 | collect(config('inertia.testing.page_paths', []))->flatMap(function($path) { 6 | $relativePath = LaravelVsCode::relativePath($path); 7 | 8 | return [$relativePath, mb_strtolower($relativePath)]; 9 | })->unique()->values(), 10 | ]); 11 | -------------------------------------------------------------------------------- /php-templates/middleware.php: -------------------------------------------------------------------------------- 1 | hasMethod('__invoke') => $reflected->getMethod('__invoke'), 6 | default => $reflected->getMethod('handle'), 7 | }; 8 | } 9 | 10 | echo collect(app("Illuminate\Contracts\Http\Kernel")->getMiddlewareGroups()) 11 | ->merge(app("Illuminate\Contracts\Http\Kernel")->getRouteMiddleware()) 12 | ->map(function ($middleware, $key) { 13 | $result = [ 14 | "class" => null, 15 | "path" => null, 16 | "line" => null, 17 | "parameters" => null, 18 | "groups" => [], 19 | ]; 20 | 21 | if (is_array($middleware)) { 22 | $result["groups"] = collect($middleware)->map(function ($m) { 23 | if (!class_exists($m)) { 24 | return [ 25 | "class" => $m, 26 | "path" => null, 27 | "line" => null 28 | ]; 29 | } 30 | 31 | $reflected = new ReflectionClass($m); 32 | $reflectedMethod = vsCodeGetReflectionMethod($reflected); 33 | 34 | return [ 35 | "class" => $m, 36 | "path" => LaravelVsCode::relativePath($reflected->getFileName()), 37 | "line" => 38 | $reflectedMethod->getFileName() === $reflected->getFileName() 39 | ? $reflectedMethod->getStartLine() 40 | : null 41 | ]; 42 | })->all(); 43 | 44 | return $result; 45 | } 46 | 47 | $reflected = new ReflectionClass($middleware); 48 | $reflectedMethod = vsCodeGetReflectionMethod($reflected); 49 | 50 | $result = array_merge($result, [ 51 | "class" => $middleware, 52 | "path" => LaravelVsCode::relativePath($reflected->getFileName()), 53 | "line" => $reflectedMethod->getStartLine(), 54 | ]); 55 | 56 | $parameters = collect($reflectedMethod->getParameters()) 57 | ->filter(function ($rc) { 58 | return $rc->getName() !== "request" && $rc->getName() !== "next"; 59 | }) 60 | ->map(function ($rc) { 61 | return $rc->getName() . ($rc->isVariadic() ? "..." : ""); 62 | }); 63 | 64 | if ($parameters->isEmpty()) { 65 | return $result; 66 | } 67 | 68 | return array_merge($result, [ 69 | "parameters" => $parameters->implode(",") 70 | ]); 71 | }) 72 | ->toJson(); 73 | -------------------------------------------------------------------------------- /php-templates/routes.php: -------------------------------------------------------------------------------- 1 | getRoutes()->getRoutes()) 7 | ->map(fn(\Illuminate\Routing\Route $route) => $this->getRoute($route)) 8 | ->merge($this->getFolioRoutes()); 9 | } 10 | 11 | protected function getFolioRoutes() 12 | { 13 | try { 14 | $output = new \Symfony\Component\Console\Output\BufferedOutput(); 15 | 16 | \Illuminate\Support\Facades\Artisan::call("folio:list", ["--json" => true], $output); 17 | 18 | $mountPaths = collect(app(\Laravel\Folio\FolioManager::class)->mountPaths()); 19 | 20 | return collect(json_decode($output->fetch(), true))->map(fn($route) => $this->getFolioRoute($route, $mountPaths)); 21 | } catch (\Exception | \Throwable $e) { 22 | return []; 23 | } 24 | } 25 | 26 | protected function getFolioRoute($route, $mountPaths) 27 | { 28 | if ($mountPaths->count() === 1) { 29 | $mountPath = $mountPaths[0]; 30 | } else { 31 | $mountPath = $mountPaths->first(fn($mp) => file_exists($mp->path . DIRECTORY_SEPARATOR . $route['view'])); 32 | } 33 | 34 | $path = $route['view']; 35 | 36 | if ($mountPath) { 37 | $path = $mountPath->path . DIRECTORY_SEPARATOR . $path; 38 | } 39 | 40 | return [ 41 | 'method' => $route['method'], 42 | 'uri' => $route['uri'], 43 | 'name' => $route['name'], 44 | 'action' => null, 45 | 'parameters' => [], 46 | 'filename' => $path, 47 | 'line' => 0, 48 | ]; 49 | } 50 | 51 | protected function getRoute(\Illuminate\Routing\Route $route) 52 | { 53 | try { 54 | $reflection = $this->getRouteReflection($route); 55 | } catch (\Throwable $e) { 56 | $reflection = null; 57 | } 58 | 59 | return [ 60 | 'method' => collect($route->methods()) 61 | ->filter(fn($method) => $method !== 'HEAD') 62 | ->implode('|'), 63 | 'uri' => $route->uri(), 64 | 'name' => $route->getName(), 65 | 'action' => $route->getActionName(), 66 | 'parameters' => $route->parameterNames(), 67 | 'filename' => $reflection ? $reflection->getFileName() : null, 68 | 'line' => $reflection ? $reflection->getStartLine() : null, 69 | ]; 70 | } 71 | 72 | protected function getRouteReflection(\Illuminate\Routing\Route $route) 73 | { 74 | if ($route->getActionName() === 'Closure') { 75 | return new \ReflectionFunction($route->getAction()['uses']); 76 | } 77 | 78 | if (!str_contains($route->getActionName(), '@')) { 79 | return new \ReflectionClass($route->getActionName()); 80 | } 81 | 82 | try { 83 | return new \ReflectionMethod($route->getControllerClass(), $route->getActionMethod()); 84 | } catch (\Throwable $e) { 85 | $namespace = app(\Illuminate\Routing\UrlGenerator::class)->getRootControllerNamespace() 86 | ?? (app()->getNamespace() . 'Http\Controllers'); 87 | 88 | return new \ReflectionMethod( 89 | $namespace . '\\' . ltrim($route->getControllerClass(), '\\'), 90 | $route->getActionMethod(), 91 | ); 92 | } 93 | } 94 | }; 95 | 96 | echo $routes->all()->toJson(); 97 | -------------------------------------------------------------------------------- /php-templates/views.php: -------------------------------------------------------------------------------- 1 | getFinder(); 7 | 8 | $paths = collect($finder->getPaths())->flatMap(fn($path) => $this->findViews($path)); 9 | 10 | $hints = collect($finder->getHints())->flatMap( 11 | fn($paths, $key) => collect($paths)->flatMap( 12 | fn($path) => collect($this->findViews($path))->map( 13 | fn($value) => array_merge($value, ["key" => "{$key}::{$value["key"]}"]) 14 | ) 15 | ) 16 | ); 17 | 18 | [$local, $vendor] = $paths 19 | ->merge($hints) 20 | ->values() 21 | ->partition(fn($v) => !$v["isVendor"]); 22 | 23 | return $local 24 | ->sortBy("key", SORT_NATURAL) 25 | ->merge($vendor->sortBy("key", SORT_NATURAL)); 26 | } 27 | 28 | public function getAllComponents() 29 | { 30 | $namespaced = \Illuminate\Support\Facades\Blade::getClassComponentNamespaces(); 31 | $autoloaded = require base_path("vendor/composer/autoload_psr4.php"); 32 | $components = []; 33 | 34 | foreach ($namespaced as $key => $ns) { 35 | $path = null; 36 | 37 | foreach ($autoloaded as $namespace => $paths) { 38 | if (str_starts_with($ns, $namespace)) { 39 | foreach ($paths as $p) { 40 | $test = str($ns)->replace($namespace, '')->replace('\\', '/')->prepend($p . DIRECTORY_SEPARATOR)->toString(); 41 | 42 | if (is_dir($test)) { 43 | $path = $test; 44 | break; 45 | } 46 | } 47 | 48 | break; 49 | } 50 | } 51 | 52 | if (!$path) { 53 | continue; 54 | } 55 | 56 | $files = \Symfony\Component\Finder\Finder::create() 57 | ->files() 58 | ->name("*.php") 59 | ->in($path); 60 | 61 | foreach ($files as $file) { 62 | $realPath = $file->getRealPath(); 63 | 64 | $components[] = [ 65 | "path" => str_replace(base_path(DIRECTORY_SEPARATOR), '', $realPath), 66 | "isVendor" => str_contains($realPath, base_path("vendor")), 67 | "key" => str($realPath) 68 | ->replace(realpath($path), "") 69 | ->replace(".php", "") 70 | ->ltrim(DIRECTORY_SEPARATOR) 71 | ->replace(DIRECTORY_SEPARATOR, ".") 72 | ->kebab() 73 | ->prepend($key . "::"), 74 | ]; 75 | } 76 | } 77 | 78 | return $components; 79 | } 80 | 81 | protected function findViews($path) 82 | { 83 | $paths = []; 84 | 85 | if (!is_dir($path)) { 86 | return $paths; 87 | } 88 | 89 | $files = \Symfony\Component\Finder\Finder::create() 90 | ->files() 91 | ->name("*.blade.php") 92 | ->in($path); 93 | 94 | foreach ($files as $file) { 95 | $paths[] = [ 96 | "path" => str_replace(base_path(DIRECTORY_SEPARATOR), '', $file->getRealPath()), 97 | "isVendor" => str_contains($file->getRealPath(), base_path("vendor")), 98 | "key" => str($file->getRealPath()) 99 | ->replace(realpath($path), "") 100 | ->replace(".blade.php", "") 101 | ->ltrim(DIRECTORY_SEPARATOR) 102 | ->replace(DIRECTORY_SEPARATOR, ".") 103 | ]; 104 | } 105 | 106 | return $paths; 107 | } 108 | }; 109 | 110 | echo json_encode($blade->getAllViews()->merge($blade->getAllComponents())); 111 | -------------------------------------------------------------------------------- /precheck: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | BINARY_VERSION=`grep 'const binaryVersion' src/support/parser.ts | sed -E 's/.*"([^"]+)".*/\1/'` 4 | 5 | read -p "Correct binary version (y/n)? $BINARY_VERSION " confirmation 6 | 7 | if [ "$confirmation" != "y" ]; then 8 | echo "Please update the binary version in src/support/parser.ts" 9 | exit 1 10 | fi 11 | 12 | read -p "Did you update the changelog? (y/n)? " confirmation 13 | 14 | if [ "$confirmation" != "y" ]; then 15 | echo "Maybe update the changelog before publishing." 16 | exit 1 17 | fi 18 | -------------------------------------------------------------------------------- /src/blade/BladeFormatter.ts: -------------------------------------------------------------------------------- 1 | export class BladeFormatter { 2 | private newLine: string = "\n"; 3 | private indentPattern: string; 4 | 5 | constructor(options?: IBladeFormatterOptions) { 6 | options = options || {}; 7 | 8 | // set default values for options 9 | options.tabSize = options.tabSize || 4; 10 | options.insertSpaces = options.insertSpaces ?? true; 11 | 12 | this.indentPattern = options.insertSpaces 13 | ? " ".repeat(options.tabSize) 14 | : "\t"; 15 | } 16 | 17 | format(inputText: string): string { 18 | let inComment = false; 19 | let output = inputText; 20 | 21 | // fix #57 url extra space after formatting 22 | output = output.replace(/url\(\"(\s*)/g, 'url("'); 23 | 24 | // return the formatted input text with trailing white spaces removed 25 | return output.trim(); 26 | } 27 | } 28 | 29 | export interface IBladeFormatterOptions { 30 | insertSpaces?: boolean; 31 | tabSize?: number; 32 | } 33 | -------------------------------------------------------------------------------- /src/blade/BladeFormattingEditProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import * as html from "vscode-html-languageservice"; 3 | import * as lst from "vscode-languageserver-textdocument"; 4 | import { BladeFormatter, IBladeFormatterOptions } from "./BladeFormatter"; 5 | 6 | const service = html.getLanguageService(); 7 | 8 | export class BladeFormattingEditProvider 9 | implements 10 | vscode.DocumentFormattingEditProvider, 11 | vscode.DocumentRangeFormattingEditProvider 12 | { 13 | private formatterOptions: IBladeFormatterOptions = {}; 14 | 15 | provideDocumentFormattingEdits( 16 | document: vscode.TextDocument, 17 | options: vscode.FormattingOptions, 18 | ): vscode.TextEdit[] { 19 | let range = new vscode.Range( 20 | new vscode.Position(0, 0), 21 | new vscode.Position(document.lineCount - 1, Number.MAX_VALUE), 22 | ); 23 | return this.provideFormattingEdits( 24 | document, 25 | document.validateRange(range), 26 | options, 27 | ); 28 | } 29 | 30 | provideDocumentRangeFormattingEdits( 31 | document: vscode.TextDocument, 32 | range: vscode.Range, 33 | options: vscode.FormattingOptions, 34 | ): vscode.TextEdit[] { 35 | return this.provideFormattingEdits(document, range, options); 36 | } 37 | 38 | private provideFormattingEdits( 39 | document: vscode.TextDocument, 40 | range: vscode.Range, 41 | options: vscode.FormattingOptions, 42 | ): vscode.TextEdit[] { 43 | this.formatterOptions = { 44 | tabSize: options.tabSize, 45 | insertSpaces: options.insertSpaces, 46 | }; 47 | 48 | // Mapping HTML format options 49 | let htmlFormatConfig = vscode.workspace.getConfiguration("html.format"); 50 | Object.assign(options, htmlFormatConfig); 51 | 52 | // format as html 53 | let doc = lst.TextDocument.create( 54 | document.uri.fsPath, 55 | "html", 56 | 1, 57 | document.getText(), 58 | ); 59 | let htmlTextEdit = service.format(doc, range, options); 60 | 61 | // format as blade 62 | let formatter = new BladeFormatter(this.formatterOptions); 63 | let bladeText = formatter.format(htmlTextEdit[0].newText); 64 | 65 | return [vscode.TextEdit.replace(range, bladeText)]; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/blade/bladeSpacer.ts: -------------------------------------------------------------------------------- 1 | import { config } from "@src/support/config"; 2 | import { 3 | Position, 4 | Range, 5 | SnippetString, 6 | TextDocument, 7 | TextDocumentChangeEvent, 8 | TextDocumentContentChangeEvent, 9 | TextEditor, 10 | } from "vscode"; 11 | 12 | const TAG_DOUBLE = 0; 13 | const TAG_UNESCAPED = 1; 14 | const TAG_COMMENT = 2; 15 | 16 | const snippets: Record = { 17 | [TAG_DOUBLE]: "{{ ${1:${TM_SELECTED_TEXT/[{}]//g}} }}$0", 18 | [TAG_UNESCAPED]: "{!! ${1:${TM_SELECTED_TEXT/[{} !]//g}} !!}$0", 19 | [TAG_COMMENT]: "{{-- ${1:${TM_SELECTED_TEXT/(--)|[{} ]//g}} --}}$0", 20 | }; 21 | 22 | const triggers = ["{}", "!", "-", "{"]; 23 | 24 | const regexes = [ 25 | /({{(?!\s|-))(.*?)(}})/, 26 | /({!!(?!\s))(.*?)?(}?)/, 27 | /({{[\s]?--)(.*?)?(}})/, 28 | ]; 29 | 30 | const translate = (position: Position, offset: number) => { 31 | try { 32 | return position.translate(0, offset); 33 | } catch (error) { 34 | // VS Code doesn't like negative numbers passed 35 | // to translate (even though it works fine), so 36 | // this block prevents debug console errors 37 | } 38 | 39 | return position; 40 | }; 41 | 42 | const charsForChange = ( 43 | doc: TextDocument, 44 | change: TextDocumentContentChangeEvent, 45 | ) => { 46 | if (change.text === "!") { 47 | return 2; 48 | } 49 | 50 | if (change.text !== "-") { 51 | return 1; 52 | } 53 | 54 | const start = translate(change.range.start, -2); 55 | const end = translate(change.range.start, -1); 56 | 57 | return doc.getText(new Range(start, end)) === " " ? 4 : 3; 58 | }; 59 | 60 | export const bladeSpacer = async ( 61 | e: TextDocumentChangeEvent, 62 | editor?: TextEditor, 63 | ) => { 64 | if ( 65 | !config("blade.autoSpaceTags", true) || 66 | !editor || 67 | editor.document.fileName.indexOf(".blade.php") === -1 68 | ) { 69 | return; 70 | } 71 | 72 | let tagType: number = -1; 73 | let ranges: Range[] = []; 74 | let offsets: number[] = []; 75 | 76 | // Changes (per line) come in right-to-left when we need them left-to-right 77 | const changes = e.contentChanges.slice().reverse(); 78 | 79 | changes.forEach((change) => { 80 | if (triggers.indexOf(change.text) === -1) { 81 | return; 82 | } 83 | 84 | if (!offsets[change.range.start.line]) { 85 | offsets[change.range.start.line] = 0; 86 | } 87 | 88 | const startOffset = 89 | offsets[change.range.start.line] - 90 | charsForChange(e.document, change); 91 | 92 | const start = translate(change.range.start, startOffset); 93 | const lineEnd = e.document.lineAt(start.line).range.end; 94 | 95 | for (let i = 0; i < regexes.length; i++) { 96 | // If we typed a - or a !, don't consider the "double" tag type 97 | if (i === TAG_DOUBLE && ["-", "!"].indexOf(change.text) !== -1) { 98 | continue; 99 | } 100 | 101 | // Only look at unescaped tags if we need to 102 | if (i === TAG_UNESCAPED && change.text !== "!") { 103 | continue; 104 | } 105 | 106 | // Only look at comment tags if we need to 107 | if (i === TAG_COMMENT && change.text !== "-") { 108 | continue; 109 | } 110 | 111 | const tag = regexes[i].exec( 112 | e.document.getText(new Range(start, lineEnd)), 113 | ); 114 | 115 | if (tag) { 116 | tagType = i; 117 | ranges.push( 118 | new Range(start, start.translate(0, tag[0].length)), 119 | ); 120 | offsets[start.line] += tag[1].length; 121 | } 122 | } 123 | }); 124 | 125 | if (ranges.length > 0 && snippets[tagType]) { 126 | editor.insertSnippet(new SnippetString(snippets[tagType]), ranges); 127 | } 128 | }; 129 | -------------------------------------------------------------------------------- /src/blade/client.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import * as vscode from "vscode"; 3 | import { 4 | LanguageClient, 5 | LanguageClientOptions, 6 | ServerOptions, 7 | TransportKind, 8 | } from "vscode-languageclient/node"; 9 | 10 | export const initClient = (context: vscode.ExtensionContext) => { 11 | // Set html indent 12 | const EMPTY_ELEMENTS: string[] = [ 13 | "area", 14 | "base", 15 | "br", 16 | "col", 17 | "embed", 18 | "hr", 19 | "img", 20 | "input", 21 | "keygen", 22 | "link", 23 | "menuitem", 24 | "meta", 25 | "param", 26 | "source", 27 | "track", 28 | "wbr", 29 | ]; 30 | 31 | vscode.languages.setLanguageConfiguration("blade", { 32 | indentationRules: { 33 | increaseIndentPattern: 34 | /<(?!\?|(?:area|base|br|col|frame|hr|html|img|input|link|meta|param)\b|[^>]*\/>)([-_\.A-Za-z0-9]+)(?=\s|>)\b[^>]*>(?!.*<\/\1>)|)|\{[^}"']*$/, 35 | decreaseIndentPattern: 36 | /^\s*(<\/(?!html)[-_\.A-Za-z0-9]+\b[^>]*>|-->|\})/, 37 | }, 38 | wordPattern: 39 | /(-?\d*\.\d\w*)|([^\`\~\!\@\$\^\&\*\(\)\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\s]+)/g, 40 | onEnterRules: [ 41 | { 42 | beforeText: new RegExp( 43 | `<(?!(?:${EMPTY_ELEMENTS.join( 44 | "|", 45 | )}))([_:\\w][_:\\w-.\\d]*)([^/>]*(?!/)>)[^<]*$`, 46 | "i", 47 | ), 48 | afterText: /^<\/([_:\w][_:\w-.\d]*)\s*>$/i, 49 | action: { indentAction: vscode.IndentAction.IndentOutdent }, 50 | }, 51 | { 52 | beforeText: new RegExp( 53 | `<(?!(?:${EMPTY_ELEMENTS.join( 54 | "|", 55 | )}))(\\w[\\w\\d]*)([^/>]*(?!/)>)[^<]*$`, 56 | "i", 57 | ), 58 | action: { indentAction: vscode.IndentAction.Indent }, 59 | }, 60 | ], 61 | }); 62 | 63 | // The server is implemented in node 64 | let serverModule = context.asAbsolutePath( 65 | path.join("server", "out", "htmlServerMain.js"), 66 | ); 67 | 68 | // If the extension is launch in debug mode the debug server options are use 69 | // Otherwise the run options are used 70 | let serverOptions: ServerOptions = { 71 | run: { module: serverModule, transport: TransportKind.ipc }, 72 | debug: { 73 | module: serverModule, 74 | transport: TransportKind.ipc, 75 | options: { execArgv: ["--nolazy", "--inspect=6045"] }, 76 | }, 77 | }; 78 | 79 | // Options to control the language client 80 | let clientOptions: LanguageClientOptions = { 81 | documentSelector: ["blade"], 82 | synchronize: { 83 | configurationSection: ["blade", "css", "javascript", "emmet"], // the settings to synchronize 84 | }, 85 | initializationOptions: { 86 | embeddedLanguages: { css: true, javascript: true }, 87 | }, 88 | }; 89 | 90 | // Create the language client and start the client. 91 | let client = new LanguageClient( 92 | "blade", 93 | "BLADE Language Server", 94 | serverOptions, 95 | clientOptions, 96 | ); 97 | 98 | client.registerProposedFeatures(); 99 | 100 | return client; 101 | }; 102 | -------------------------------------------------------------------------------- /src/codeAction/codeActionProvider.ts: -------------------------------------------------------------------------------- 1 | import { codeActionProvider as envProvider } from "@src/features/env"; 2 | import { codeActionProvider as inertiaProvider } from "@src/features/inertia"; 3 | import { codeActionProvider as viewProvider } from "@src/features/view"; 4 | import * as vscode from "vscode"; 5 | import { CodeActionProviderFunction } from ".."; 6 | 7 | const providers: CodeActionProviderFunction[] = [ 8 | envProvider, 9 | inertiaProvider, 10 | viewProvider, 11 | ]; 12 | 13 | export class CodeActionProvider implements vscode.CodeActionProvider { 14 | public static readonly providedCodeActionKinds = [ 15 | vscode.CodeActionKind.QuickFix, 16 | ]; 17 | 18 | provideCodeActions( 19 | document: vscode.TextDocument, 20 | range: vscode.Range | vscode.Selection, 21 | context: vscode.CodeActionContext, 22 | token: vscode.CancellationToken, 23 | ): vscode.ProviderResult { 24 | return Promise.all( 25 | context.diagnostics 26 | .map((diagnostic) => 27 | providers.map((provider) => 28 | provider(diagnostic, document, range, token), 29 | ), 30 | ) 31 | .flat(), 32 | ).then((actions) => actions.flat()); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/codeAction/support.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laravel/vs-code-extension/821442f3698a2f3e3da3797fb6783115a40c4873/src/codeAction/support.ts -------------------------------------------------------------------------------- /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | export const openFile = ( 4 | uri: vscode.Uri | string, 5 | lineNumber: number, 6 | position: number, 7 | ): vscode.Command => { 8 | if (typeof uri === "string") { 9 | uri = vscode.Uri.file(uri); 10 | } 11 | 12 | return { 13 | command: "laravel.open", 14 | title: "Open file", 15 | arguments: [uri, lineNumber, position], 16 | }; 17 | }; 18 | 19 | export const openFileCommand = ( 20 | uri: vscode.Uri, 21 | lineNumber: number, 22 | position: number, 23 | ) => { 24 | vscode.window.showTextDocument(uri, { 25 | selection: new vscode.Range( 26 | new vscode.Position(lineNumber, position), 27 | new vscode.Position(lineNumber, position), 28 | ), 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /src/completion/CompletionProvider.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import { completionProvider as appBinding } from "@src/features/appBinding"; 4 | import { completionProvider as asset } from "@src/features/asset"; 5 | import { completionProvider as auth } from "@src/features/auth"; 6 | import { completionProvider as config } from "@src/features/config"; 7 | import { completionProvider as controllerAction } from "@src/features/controllerAction"; 8 | import { completionProvider as env } from "@src/features/env"; 9 | import { completionProvider as inertia } from "@src/features/inertia"; 10 | import { completionProvider as middleware } from "@src/features/middleware"; 11 | import { completionProvider as mix } from "@src/features/mix"; 12 | import { completionProvider as route } from "@src/features/route"; 13 | import { completionProvider as storage } from "@src/features/storage"; 14 | import { completionProvider as translation } from "@src/features/translation"; 15 | import { completionProvider as view } from "@src/features/view"; 16 | import { GeneratedConfigKey } from "@src/support/generated-config"; 17 | import { CompletionProvider } from ".."; 18 | 19 | const allProviders: Partial> = { 20 | "appBinding.completion": appBinding, 21 | "asset.completion": asset, 22 | "auth.completion": auth, 23 | "config.completion": config, 24 | "controllerAction.completion": controllerAction, 25 | "env.completion": env, 26 | "inertia.completion": inertia, 27 | "middleware.completion": middleware, 28 | "mix.completion": mix, 29 | "route.completion": route, 30 | "storage.completion": storage, 31 | "translation.completion": translation, 32 | "view.completion": view, 33 | }; 34 | 35 | export const completionProviders = Object.entries(allProviders).map( 36 | ([_, provider]) => provider, 37 | ); 38 | -------------------------------------------------------------------------------- /src/completion/Registry.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import AutocompleteResult from "@src/parser/AutocompleteResult"; 4 | import { parseForAutocomplete } from "@src/support/parser"; 5 | import { toArray } from "@src/support/util"; 6 | import * as vscode from "vscode"; 7 | import { CompletionProvider, FeatureTagParam } from ".."; 8 | 9 | export default class Registry implements vscode.CompletionItemProvider { 10 | private providers: CompletionProvider[] = []; 11 | 12 | constructor(...providers: CompletionProvider[]) { 13 | this.providers.push(...providers); 14 | } 15 | 16 | provideCompletionItems( 17 | document: vscode.TextDocument, 18 | position: vscode.Position, 19 | token: vscode.CancellationToken, 20 | context: vscode.CompletionContext, 21 | ): vscode.ProviderResult< 22 | vscode.CompletionItem[] | vscode.CompletionList 23 | > { 24 | const docUntilPosition = document.getText( 25 | new vscode.Range(0, 0, position.line, position.character), 26 | ); 27 | 28 | return parseForAutocomplete(docUntilPosition).then((parseResult) => { 29 | if (parseResult === null) { 30 | return []; 31 | } 32 | 33 | let provider = this.getProviderFromResult(parseResult); 34 | 35 | if (!provider) { 36 | provider = this.getProviderByFallback( 37 | parseResult, 38 | docUntilPosition, 39 | ); 40 | } 41 | 42 | if (!provider) { 43 | return []; 44 | } 45 | 46 | return provider.provideCompletionItems( 47 | parseResult, 48 | document, 49 | position, 50 | token, 51 | context, 52 | ); 53 | }); 54 | } 55 | 56 | private getProviderByClassOrFunction( 57 | parseResult: AutocompleteResult, 58 | ): CompletionProvider | null { 59 | const hasFunc = (funcs: FeatureTagParam["method"]) => { 60 | if (typeof funcs === "undefined" || funcs === null) { 61 | return parseResult.func() === null; 62 | } 63 | 64 | if (typeof funcs === "string") { 65 | return funcs === parseResult.func(); 66 | } 67 | 68 | return funcs.find((fn) => fn === parseResult.func()) !== undefined; 69 | }; 70 | 71 | const hasClass = (classes: FeatureTagParam["class"]) => { 72 | if (typeof classes === "undefined" || classes === null) { 73 | return parseResult.class() === null; 74 | } 75 | 76 | if (typeof classes === "string") { 77 | return classes === parseResult.class(); 78 | } 79 | 80 | return ( 81 | classes.find((fn) => fn === parseResult.class()) !== undefined 82 | ); 83 | }; 84 | 85 | const isArgumentIndex = ( 86 | argumentIndex: number | number[] | undefined, 87 | ) => { 88 | if (typeof argumentIndex === "undefined") { 89 | return true; 90 | } 91 | 92 | if (Array.isArray(argumentIndex)) { 93 | return argumentIndex.includes(parseResult.paramIndex()); 94 | } 95 | 96 | return argumentIndex === parseResult.paramIndex(); 97 | }; 98 | 99 | const isNamedArg = (argumentName: FeatureTagParam["argumentName"]) => { 100 | // TODO: Make this work 101 | return true; 102 | 103 | // if (typeof argumentName === "undefined") { 104 | // return true; 105 | // } 106 | 107 | // if (Array.isArray(argumentName)) { 108 | // return argumentName.includes(parseResult.argumentName()); 109 | // } 110 | 111 | // return argumentName === parseResult.argumentName(); 112 | }; 113 | 114 | return ( 115 | this.providers.find((provider) => { 116 | return toArray(provider.tags()).find( 117 | (tag) => 118 | hasClass(tag.class) && 119 | hasFunc(tag.method) && 120 | isArgumentIndex(tag.argumentIndex) && 121 | isNamedArg(tag.argumentName), 122 | ); 123 | }) || null 124 | ); 125 | } 126 | 127 | private getProviderByFallback( 128 | parseResult: AutocompleteResult, 129 | document: string, 130 | ): CompletionProvider | null { 131 | for (const provider of this.providers) { 132 | if (!provider.customCheck) { 133 | continue; 134 | } 135 | 136 | const result = provider.customCheck(parseResult, document); 137 | 138 | if (result !== false) { 139 | return provider; 140 | } 141 | } 142 | 143 | return null; 144 | } 145 | 146 | private getProviderFromResult( 147 | parseResult: AutocompleteResult, 148 | ): CompletionProvider | null { 149 | return this.getProviderByClassOrFunction(parseResult); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/diagnostic/diagnostic.ts: -------------------------------------------------------------------------------- 1 | import { diagnosticProvider as appBinding } from "@src/features/appBinding"; 2 | import { diagnosticProvider as asset } from "@src/features/asset"; 3 | import { diagnosticProvider as auth } from "@src/features/auth"; 4 | import { diagnosticProvider as config } from "@src/features/config"; 5 | import { diagnosticProvider as controllerAction } from "@src/features/controllerAction"; 6 | import { diagnosticProvider as env } from "@src/features/env"; 7 | import { diagnosticProvider as inertia } from "@src/features/inertia"; 8 | import { diagnosticProvider as middleware } from "@src/features/middleware"; 9 | import { diagnosticProvider as mix } from "@src/features/mix"; 10 | import { diagnosticProvider as route } from "@src/features/route"; 11 | import { diagnosticProvider as storage } from "@src/features/storage"; 12 | import { diagnosticProvider as translation } from "@src/features/translation"; 13 | import { diagnosticProvider as view } from "@src/features/view"; 14 | import { config as getConfig } from "@src/support/config"; 15 | import { GeneratedConfigKey } from "@src/support/generated-config"; 16 | import * as vscode from "vscode"; 17 | 18 | const collection = vscode.languages.createDiagnosticCollection("laravel"); 19 | 20 | const providers: { 21 | provider: (doc: vscode.TextDocument) => Promise; 22 | configKey: GeneratedConfigKey; 23 | }[] = [ 24 | { provider: appBinding, configKey: "appBinding.diagnostics" }, 25 | { provider: asset, configKey: "asset.diagnostics" }, 26 | { provider: auth, configKey: "auth.diagnostics" }, 27 | { provider: config, configKey: "config.diagnostics" }, 28 | { provider: controllerAction, configKey: "controllerAction.diagnostics" }, 29 | { provider: env, configKey: "env.diagnostics" }, 30 | { provider: inertia, configKey: "inertia.diagnostics" }, 31 | { provider: middleware, configKey: "middleware.diagnostics" }, 32 | { provider: mix, configKey: "mix.diagnostics" }, 33 | { provider: route, configKey: "route.diagnostics" }, 34 | { provider: storage, configKey: "storage.diagnostics" }, 35 | { provider: translation, configKey: "translation.diagnostics" }, 36 | { provider: view, configKey: "view.diagnostics" }, 37 | ]; 38 | 39 | export const updateDiagnostics = ( 40 | editor: vscode.TextEditor | undefined, 41 | ): void => { 42 | collection.clear(); 43 | 44 | const document = editor?.document; 45 | 46 | if (!document) { 47 | return; 48 | } 49 | 50 | if (!["php", "blade", "laravel-blade"].includes(document.languageId)) { 51 | return; 52 | } 53 | 54 | Promise.all( 55 | providers 56 | .filter((provider) => getConfig(provider.configKey, true)) 57 | .map((provider) => provider.provider(document)), 58 | ).then((diagnostics) => { 59 | collection.set(document.uri, diagnostics.flat(2)); 60 | }); 61 | }; 62 | -------------------------------------------------------------------------------- /src/diagnostic/index.ts: -------------------------------------------------------------------------------- 1 | import { Diagnostic, DiagnosticSeverity, Range } from "vscode"; 2 | 3 | export const notFound = ( 4 | descriptor: string, 5 | match: string, 6 | range: Range, 7 | code: DiagnosticCode, 8 | ): Diagnostic => ({ 9 | message: `${descriptor} [${match}] not found.`, 10 | severity: DiagnosticSeverity.Warning, 11 | range, 12 | source: "Laravel Extension", 13 | code, 14 | }); 15 | 16 | export type DiagnosticCode = 17 | | "appBinding" 18 | | "asset" 19 | | "auth" 20 | | "config" 21 | | "controllerAction" 22 | | "env" 23 | | "inertia" 24 | | "middleware" 25 | | "mix" 26 | | "route" 27 | | "translation" 28 | | "view" 29 | | "storage_disk"; 30 | -------------------------------------------------------------------------------- /src/downloaders/IGitHubRelease.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | export interface IGithubRelease { 3 | url: string; 4 | assets_url: string; 5 | upload_url: string; 6 | html_url: string; 7 | id: number; 8 | author: object; 9 | node_id: string; 10 | tag_name: string; 11 | target_commitish: string; 12 | name: string; 13 | draft: boolean; 14 | prerelease: boolean; 15 | created_at: Date; 16 | published_at: Date; 17 | assets: IAsset[]; 18 | tarball_url: string; 19 | zipball_url: string; 20 | body: string; 21 | mentions_count: number; 22 | } 23 | 24 | interface IAsset { 25 | url: string; 26 | id: number; 27 | node_id: string; 28 | name: string; 29 | label: string; 30 | uploader: object; 31 | content_type: string; 32 | state: string; 33 | size: number; 34 | download_count: number; 35 | created_at: Date; 36 | updated_at: Date; 37 | browser_download_url: string; 38 | } 39 | -------------------------------------------------------------------------------- /src/downloaders/logging/ILogger.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | export default interface ILogger { 5 | log(message: string): void; 6 | warn(message: string): void; 7 | error(message: string): void; 8 | } -------------------------------------------------------------------------------- /src/downloaders/logging/OutputLogger.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | import { ExtensionContext, OutputChannel, window } from "vscode"; 5 | import ILogger from "./ILogger"; 6 | 7 | export default class OutputLogger implements ILogger { 8 | private readonly _outputChannel: OutputChannel; 9 | 10 | public constructor(extensionName: string, context: ExtensionContext) { 11 | this._outputChannel = window.createOutputChannel(extensionName); 12 | context.subscriptions.push(this._outputChannel); 13 | } 14 | 15 | public log(message: string): void { 16 | this._outputChannel.appendLine(message); 17 | } 18 | 19 | public warn(message: string): void { 20 | this.log(`Warning: ${message}`); 21 | } 22 | 23 | public error(message: string): void { 24 | this.log(`ERROR: ${message}`); 25 | } 26 | } -------------------------------------------------------------------------------- /src/downloaders/networking/HttpRequestHandler.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | import { Readable } from "stream"; 5 | import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; 6 | import { CancellationToken } from "vscode"; 7 | import ILogger from "../logging/ILogger"; 8 | import { RetryUtility } from "../utility/RetryUtility"; 9 | import IHttpRequestHandler from "./IHttpRequestHandler"; 10 | 11 | export default class HttpRequestHandler implements IHttpRequestHandler { 12 | public constructor(private readonly _logger: ILogger) { } 13 | 14 | public async get( 15 | url: string, 16 | timeoutInMs: number, 17 | retries: number, 18 | retryDelayInMs: number, 19 | headers?: Record, 20 | cancellationToken?: CancellationToken, 21 | onDownloadProgressChange?: (downloadedBytes: number, totalBytes: number) => void 22 | ): Promise { 23 | const requestFn = () => this.getRequestHelper( 24 | url, 25 | timeoutInMs, 26 | headers, 27 | cancellationToken, 28 | onDownloadProgressChange 29 | ); 30 | const errorHandlerFn = (error: Error) => { 31 | const statusCode = (error as any)?.response?.status; 32 | if (statusCode != null && 400 <= statusCode && statusCode < 500) { 33 | throw error; 34 | } 35 | }; 36 | return RetryUtility.exponentialRetryAsync(requestFn, `HttpRequestHandler.get`, retries, retryDelayInMs, errorHandlerFn); 37 | } 38 | 39 | private async getRequestHelper( 40 | url: string, 41 | timeoutInMs: number, 42 | headers?: Record, 43 | cancellationToken?: CancellationToken, 44 | onDownloadProgressChange?: (downloadedBytes: number, totalBytes: number) => void 45 | ): Promise { 46 | const options: AxiosRequestConfig = { 47 | timeout: timeoutInMs, 48 | responseType: `stream`, 49 | proxy: false, // Disabling axios proxy support allows VS Code proxy settings to take effect. 50 | headers 51 | }; 52 | 53 | if (cancellationToken != null) { 54 | const cancelToken = axios.CancelToken; 55 | const cancelTokenSource = cancelToken.source(); 56 | cancellationToken.onCancellationRequested(() => cancelTokenSource.cancel()); 57 | options.cancelToken = cancelTokenSource.token; 58 | } 59 | 60 | let response: AxiosResponse | undefined; 61 | try { 62 | response = await axios.get(url, options); 63 | if (response === undefined) { 64 | throw new Error(`Undefined response received when downloading from '${url}'`); 65 | } 66 | } 67 | catch (error) { 68 | if (error instanceof Error) { 69 | this._logger.error(`${error.message}. Technical details: ${JSON.stringify(error)}`); 70 | } 71 | throw error; 72 | } 73 | 74 | const downloadStream: Readable = response.data; 75 | 76 | if (cancellationToken != null) { 77 | cancellationToken.onCancellationRequested(() => { 78 | downloadStream.destroy(); 79 | }); 80 | } 81 | 82 | // We should not feed of the data handler here if we are going to pipe the downloadStream later on. 83 | // Doing this forks the pipe and creates problem e.g. FILE_ENDED, PREMATURE_CLOSE 84 | // https://nodejs.org/api/stream.html#stream_choose_one_api_style 85 | // We should make this progress reporter code a transform pipe and place it between the downloadStream and the unzipper. 86 | // if (onDownloadProgressChange != null) { 87 | // const headers: { [key: string]: any } = response.headers; 88 | // const contentLength = parseInt(headers[`content-length`], 10); 89 | // const totalBytes = contentLength as number ?? undefined; 90 | // let downloadedBytes = 0; 91 | 92 | // downloadStream.on(`data`, (chunk: Buffer) => { 93 | // downloadedBytes += chunk.length; 94 | // if (onDownloadProgressChange != null) { 95 | // onDownloadProgressChange(downloadedBytes, totalBytes); 96 | // } 97 | // }); 98 | // } 99 | 100 | return downloadStream; 101 | } 102 | } -------------------------------------------------------------------------------- /src/downloaders/networking/IHttpRequestHandler.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | import { Readable } from "stream"; 5 | import { CancellationToken } from "vscode"; 6 | 7 | export default interface IHttpRequestHandler { 8 | get( 9 | url: string, 10 | timeoutInMs: number, 11 | retries: number, 12 | retryDelayInMs: number, 13 | headers?: Record, 14 | cancellationToken?: CancellationToken, 15 | onDownloadProgressChange?: (downloadedBytes: number, totalBytes: number) => void 16 | ): Promise; 17 | } -------------------------------------------------------------------------------- /src/downloaders/utility/Errors.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | // see https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-2.html#support-for-newtarget 5 | 6 | export class DownloadCanceledError extends Error { 7 | public constructor() { 8 | super(`Download canceled.`); 9 | Object.setPrototypeOf(this, new.target.prototype); 10 | this.name = DownloadCanceledError.name; 11 | } 12 | } 13 | 14 | export class FileNotFoundError extends Error { 15 | public constructor(path: string) { 16 | super(`File not found at path ${path}.`); 17 | Object.setPrototypeOf(this, new.target.prototype); 18 | this.name = FileNotFoundError.name; 19 | } 20 | } 21 | 22 | export class RetriesExceededError extends Error { 23 | public constructor(error: Error, operationName: string) { 24 | super(`Maximum number of retries exceeded. The operation '${operationName}' failed with error: ${error.message}. Technical details: ${JSON.stringify(error)}`); 25 | Object.setPrototypeOf(this, new.target.prototype); 26 | this.name = `${RetriesExceededError.name} for operation '${operationName}'`; 27 | } 28 | } 29 | 30 | export class ErrorUtils { 31 | public static isErrnoException(object: unknown): object is NodeJS.ErrnoException { 32 | return Object.prototype.hasOwnProperty.call(object, `code`) 33 | || Object.prototype.hasOwnProperty.call(object, `errno`); 34 | } 35 | } -------------------------------------------------------------------------------- /src/downloaders/utility/RetryUtility.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | import { RetriesExceededError } from "./Errors"; 5 | 6 | export class RetryUtility { 7 | public static async exponentialRetryAsync( 8 | requestFn: () => Promise, 9 | operationName: string, 10 | retries: number, 11 | initialDelayInMs: number, 12 | errorHandlerFn?: (error: Error) => void 13 | ): Promise { 14 | try { 15 | return await requestFn(); 16 | } 17 | catch (error) { 18 | if (error instanceof Error) { 19 | if (retries === 0) { 20 | throw new RetriesExceededError(error, operationName); 21 | } 22 | 23 | if (errorHandlerFn != null) { 24 | errorHandlerFn(error); 25 | } 26 | } 27 | await new Promise((resolve): void => { 28 | setTimeout(resolve, initialDelayInMs); 29 | }); 30 | return this.exponentialRetryAsync(requestFn, operationName, retries - 1, initialDelayInMs * 2, errorHandlerFn); 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /src/downloaders/utility/Stream.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | import { promisify } from "util"; 5 | import { pipeline, finished } from "stream"; 6 | 7 | type AnyStream = NodeJS.ReadableStream | NodeJS.WritableStream | NodeJS.ReadWriteStream; 8 | 9 | export const finishedAsync = promisify(finished); 10 | export let pipelineAsync = promisify(pipeline); 11 | 12 | // Workaround https://github.com/nodejs/node/issues/40191 13 | // todo: remove it when fix is released in 16.x and in AppEngine 16.x 14 | if (process.version > `v16.13`) { 15 | // eslint-disable-next-line @typescript-eslint/no-var-requires 16 | const { pipeline } = require(`stream/promises`); 17 | pipelineAsync = ((streams: AnyStream[]) => pipeline(...streams)) as any; 18 | } -------------------------------------------------------------------------------- /src/features/appBinding.ts: -------------------------------------------------------------------------------- 1 | import { notFound } from "@src/diagnostic"; 2 | import AutocompleteResult from "@src/parser/AutocompleteResult"; 3 | import { getAppBindings } from "@src/repositories/appBinding"; 4 | import { config } from "@src/support/config"; 5 | import { findHoverMatchesInDoc } from "@src/support/doc"; 6 | import { detectedRange, detectInDoc } from "@src/support/parser"; 7 | import { wordMatchRegex } from "@src/support/patterns"; 8 | import { projectPath } from "@src/support/project"; 9 | import { contract, facade } from "@src/support/util"; 10 | import * as vscode from "vscode"; 11 | import { 12 | CompletionProvider, 13 | FeatureTag, 14 | HoverProvider, 15 | LinkProvider, 16 | } from ".."; 17 | 18 | const toFind: FeatureTag = [ 19 | { 20 | class: [contract("Container\\Container"), contract("Foundation\\Application")], 21 | method: ["make", "bound"], 22 | argumentIndex: 0, 23 | }, 24 | { 25 | class: [...facade("App"), "app"], 26 | method: ["make", "bound", "isShared"], 27 | argumentIndex: 0, 28 | }, 29 | { method: "app", argumentIndex: 0 }, 30 | ]; 31 | 32 | const toUrl = (path: string, line: number) => { 33 | return vscode.Uri.file(projectPath(path)).with({ 34 | fragment: `L${line}`, 35 | }); 36 | }; 37 | 38 | export const linkProvider: LinkProvider = (doc: vscode.TextDocument) => { 39 | return detectInDoc( 40 | doc, 41 | toFind, 42 | getAppBindings, 43 | ({ param }) => { 44 | const binding = getAppBindings().items[param.value]; 45 | 46 | if (!binding) { 47 | return null; 48 | } 49 | 50 | return new vscode.DocumentLink( 51 | detectedRange(param), 52 | toUrl(binding.path, binding.line), 53 | ); 54 | }, 55 | ); 56 | }; 57 | 58 | export const hoverProvider: HoverProvider = ( 59 | doc: vscode.TextDocument, 60 | pos: vscode.Position, 61 | ): vscode.ProviderResult => { 62 | return findHoverMatchesInDoc(doc, pos, toFind, getAppBindings, (match) => { 63 | const binding = getAppBindings().items[match]; 64 | 65 | if (!binding) { 66 | return null; 67 | } 68 | 69 | return new vscode.Hover( 70 | new vscode.MarkdownString( 71 | [ 72 | "`" + binding.class + "`", 73 | `[${binding.path}](${toUrl(binding.path, binding.line)})`, 74 | ].join("\n\n"), 75 | ), 76 | ); 77 | }); 78 | }; 79 | 80 | export const diagnosticProvider = ( 81 | doc: vscode.TextDocument, 82 | ): Promise => { 83 | return detectInDoc( 84 | doc, 85 | toFind, 86 | getAppBindings, 87 | ({ param }) => { 88 | const appBinding = getAppBindings().items[param.value]; 89 | 90 | if (appBinding) { 91 | return null; 92 | } 93 | 94 | return notFound( 95 | "App binding", 96 | param.value, 97 | detectedRange(param), 98 | "appBinding", 99 | ); 100 | }, 101 | ); 102 | }; 103 | 104 | export const completionProvider: CompletionProvider = { 105 | tags() { 106 | return toFind; 107 | }, 108 | 109 | provideCompletionItems( 110 | result: AutocompleteResult, 111 | document: vscode.TextDocument, 112 | position: vscode.Position, 113 | token: vscode.CancellationToken, 114 | context: vscode.CompletionContext, 115 | ): vscode.CompletionItem[] { 116 | if (!config("appBinding.completion", true)) { 117 | return []; 118 | } 119 | 120 | return Object.entries(getAppBindings().items).map(([key, value]) => { 121 | let completeItem = new vscode.CompletionItem( 122 | key, 123 | vscode.CompletionItemKind.Constant, 124 | ); 125 | 126 | completeItem.range = document.getWordRangeAtPosition( 127 | position, 128 | wordMatchRegex, 129 | ); 130 | 131 | return completeItem; 132 | }); 133 | }, 134 | }; 135 | -------------------------------------------------------------------------------- /src/features/asset.ts: -------------------------------------------------------------------------------- 1 | import { notFound } from "@src/diagnostic"; 2 | import AutocompleteResult from "@src/parser/AutocompleteResult"; 3 | import { getAssets } from "@src/repositories/asset"; 4 | import { config } from "@src/support/config"; 5 | import { detectedRange, detectInDoc } from "@src/support/parser"; 6 | import { wordMatchRegex } from "@src/support/patterns"; 7 | import { contract } from "@src/support/util"; 8 | import * as vscode from "vscode"; 9 | import { FeatureTag, LinkProvider } from ".."; 10 | 11 | const toFind: FeatureTag = [ 12 | { class: null, method: "asset", argumentIndex: 0 }, 13 | { 14 | class: contract("Routing\\UrlGenerator"), 15 | method: "asset", 16 | argumentIndex: 0 17 | } 18 | ]; 19 | 20 | export const linkProvider: LinkProvider = (doc: vscode.TextDocument) => { 21 | return detectInDoc( 22 | doc, 23 | toFind, 24 | getAssets, 25 | ({ param }) => { 26 | const asset = getAssets().items.find( 27 | (asset) => asset.path === param.value, 28 | )?.uri; 29 | 30 | if (!asset) { 31 | return null; 32 | } 33 | 34 | return new vscode.DocumentLink( 35 | detectedRange(param), 36 | vscode.Uri.file(asset.path), 37 | ); 38 | }, 39 | ); 40 | }; 41 | 42 | export const diagnosticProvider = ( 43 | doc: vscode.TextDocument, 44 | ): Promise => { 45 | return detectInDoc( 46 | doc, 47 | toFind, 48 | getAssets, 49 | ({ param }) => { 50 | const asset = getAssets().items.find( 51 | (item) => item.path === param.value, 52 | ); 53 | 54 | if (asset) { 55 | return null; 56 | } 57 | 58 | return notFound( 59 | "Asset", 60 | param.value, 61 | detectedRange(param), 62 | "asset", 63 | ); 64 | }, 65 | ); 66 | }; 67 | 68 | export const completionProvider = { 69 | tags() { 70 | return toFind; 71 | }, 72 | 73 | provideCompletionItems( 74 | result: AutocompleteResult, 75 | document: vscode.TextDocument, 76 | position: vscode.Position, 77 | token: vscode.CancellationToken, 78 | context: vscode.CompletionContext, 79 | ): vscode.CompletionItem[] { 80 | if (!config("asset.completion", true)) { 81 | return []; 82 | } 83 | 84 | return getAssets().items.map((file) => { 85 | let completeItem = new vscode.CompletionItem( 86 | file.path, 87 | vscode.CompletionItemKind.Constant, 88 | ); 89 | 90 | completeItem.range = document.getWordRangeAtPosition( 91 | position, 92 | wordMatchRegex, 93 | ); 94 | 95 | return completeItem; 96 | }); 97 | }, 98 | }; 99 | -------------------------------------------------------------------------------- /src/features/bladeComponent.ts: -------------------------------------------------------------------------------- 1 | import { getBladeComponents } from "@src/repositories/bladeComponents"; 2 | import { config } from "@src/support/config"; 3 | import { projectPath } from "@src/support/project"; 4 | import * as vscode from "vscode"; 5 | import { HoverProvider, LinkProvider } from ".."; 6 | 7 | export const linkProvider: LinkProvider = (doc: vscode.TextDocument) => { 8 | const links: vscode.DocumentLink[] = []; 9 | const text = doc.getText(); 10 | const lines = text.split("\n"); 11 | const components = getBladeComponents().items; 12 | const regexes = [new RegExp(/<\/?x-([^\s>]+)/)]; 13 | 14 | if (components.prefixes.length > 0) { 15 | regexes.push( 16 | new RegExp(`<\\/?((${components.prefixes.join("|")})\\:[^\\s>]+)`), 17 | ); 18 | } 19 | 20 | lines.forEach((line, index) => { 21 | for (const regex of regexes) { 22 | const match = line.match(regex); 23 | 24 | if (!match || match.index === undefined) { 25 | continue; 26 | } 27 | 28 | const component = components.components[match[1]]; 29 | 30 | if (!component) { 31 | return; 32 | } 33 | 34 | const path = 35 | component.paths.find((p) => p.endsWith(".blade.php")) || 36 | component.paths[0]; 37 | 38 | links.push( 39 | new vscode.DocumentLink( 40 | new vscode.Range( 41 | new vscode.Position(index, match.index + 1), 42 | new vscode.Position( 43 | index, 44 | match.index + match[0].length, 45 | ), 46 | ), 47 | vscode.Uri.file(projectPath(path)), 48 | ), 49 | ); 50 | } 51 | }); 52 | 53 | return Promise.resolve(links); 54 | }; 55 | 56 | export const completionProvider: vscode.CompletionItemProvider = { 57 | provideCompletionItems( 58 | doc: vscode.TextDocument, 59 | pos: vscode.Position, 60 | ): vscode.ProviderResult { 61 | if (!config("bladeComponent.completion", true)) { 62 | return undefined; 63 | } 64 | 65 | const components = getBladeComponents().items; 66 | 67 | const componentPrefixes = ["x", "x-"].concat(components.prefixes); 68 | const line = doc.lineAt(pos.line).text; 69 | 70 | const match = componentPrefixes.find((prefix) => { 71 | const linePrefix = line.substring( 72 | pos.character - prefix.length, 73 | pos.character, 74 | ); 75 | 76 | return linePrefix !== prefix; 77 | }); 78 | 79 | if (!match) { 80 | return undefined; 81 | } 82 | 83 | return Object.keys(components.components).map((key) => { 84 | if (key.includes("::") || !key.includes(":")) { 85 | return new vscode.CompletionItem(`x-${key}`); 86 | } 87 | 88 | return new vscode.CompletionItem(key); 89 | }); 90 | }, 91 | }; 92 | 93 | export const hoverProvider: HoverProvider = ( 94 | doc: vscode.TextDocument, 95 | pos: vscode.Position, 96 | ): vscode.ProviderResult => { 97 | const components = getBladeComponents().items; 98 | const regexes = [new RegExp(/<\/?x-([^\s>]+)/)]; 99 | 100 | if (components.prefixes.length > 0) { 101 | regexes.push( 102 | new RegExp(`<\\/?((${components.prefixes.join("|")})\\:[^\\s>]+)`), 103 | ); 104 | } 105 | 106 | for (const regex of regexes) { 107 | const linkRange = doc.getWordRangeAtPosition(pos, regex); 108 | 109 | if (!linkRange) { 110 | continue; 111 | } 112 | 113 | const match = doc 114 | .getText(linkRange) 115 | .replace("<", "") 116 | .replace("/", "") 117 | .replace("x-", ""); 118 | 119 | const component = components.components[match]; 120 | 121 | if (!component) { 122 | return null; 123 | } 124 | 125 | const lines = component.paths.map( 126 | (path) => `[${path}](${vscode.Uri.file(projectPath(path))})`, 127 | ); 128 | 129 | lines.push( 130 | ...component.props.map((prop) => 131 | [ 132 | "`" + prop.type + "` ", 133 | "`" + prop.name + "`", 134 | prop.default ? ` = ${prop.default}` : "", 135 | ].join(""), 136 | ), 137 | ); 138 | 139 | return new vscode.Hover(new vscode.MarkdownString(lines.join("\n\n"))); 140 | } 141 | 142 | return null; 143 | }; 144 | -------------------------------------------------------------------------------- /src/features/controllerAction.ts: -------------------------------------------------------------------------------- 1 | import { notFound } from "@src/diagnostic"; 2 | import AutocompleteResult from "@src/parser/AutocompleteResult"; 3 | import { getControllers } from "@src/repositories/controllers"; 4 | import { getRoutes } from "@src/repositories/routes"; 5 | import { config } from "@src/support/config"; 6 | import { detectedRange, detectInDoc } from "@src/support/parser"; 7 | import { wordMatchRegex } from "@src/support/patterns"; 8 | import { contract, facade } from "@src/support/util"; 9 | import { AutocompleteParsingResult } from "@src/types"; 10 | import * as vscode from "vscode"; 11 | import { LinkProvider } from ".."; 12 | 13 | const toFind = [ 14 | { 15 | class: contract("Routing\\Registrar"), 16 | method: [ 17 | "get", 18 | "post", 19 | "patch", 20 | "put", 21 | "delete", 22 | "options", 23 | "match", 24 | ] 25 | }, 26 | { 27 | class: facade("Route"), 28 | method: [ 29 | "get", 30 | "post", 31 | "patch", 32 | "put", 33 | "delete", 34 | "options", 35 | "any", 36 | "match", 37 | "fallback", 38 | "addRoute", 39 | "newRoute", 40 | ], 41 | } 42 | ]; 43 | 44 | const isCorrectIndexForMethod = ( 45 | item: AutocompleteParsingResult.ContextValue, 46 | index: number, 47 | ) => { 48 | const indices: Record = { 49 | fallback: 0, 50 | match: 2, 51 | newRoute: 2, 52 | addRoute: 2, 53 | }; 54 | 55 | // @ts-ignore 56 | return index === (indices[item.methodName ?? ""] ?? 1); 57 | }; 58 | 59 | export const linkProvider: LinkProvider = (doc: vscode.TextDocument) => { 60 | return detectInDoc( 61 | doc, 62 | toFind, 63 | getRoutes, 64 | ({ param, item, index }) => { 65 | if (!isCorrectIndexForMethod(item, index)) { 66 | return null; 67 | } 68 | 69 | const route = getRoutes().items.find( 70 | (route) => route.action === param.value, 71 | ); 72 | 73 | if (!route || !route.filename || !route.line) { 74 | return null; 75 | } 76 | 77 | return new vscode.DocumentLink( 78 | detectedRange(param), 79 | vscode.Uri.file(route.filename).with({ 80 | fragment: `L${route.line}`, 81 | }), 82 | ); 83 | }, 84 | ); 85 | }; 86 | 87 | export const diagnosticProvider = ( 88 | doc: vscode.TextDocument, 89 | ): Promise => { 90 | return detectInDoc( 91 | doc, 92 | toFind, 93 | getRoutes, 94 | ({ param, item, index }) => { 95 | if (!isCorrectIndexForMethod(item, index)) { 96 | return null; 97 | } 98 | 99 | const action = param.value; 100 | 101 | if (!action.includes("@")) { 102 | // Intelliphense can take it from here 103 | return null; 104 | } 105 | 106 | const route = getRoutes().items.find((r) => r.action === action); 107 | 108 | if (route) { 109 | return null; 110 | } 111 | 112 | return notFound( 113 | "Controller/Method", 114 | param.value, 115 | detectedRange(param), 116 | "controllerAction", 117 | ); 118 | }, 119 | ); 120 | }; 121 | 122 | export const completionProvider = { 123 | tags() { 124 | return toFind; 125 | }, 126 | 127 | provideCompletionItems( 128 | result: AutocompleteResult, 129 | document: vscode.TextDocument, 130 | position: vscode.Position, 131 | token: vscode.CancellationToken, 132 | context: vscode.CompletionContext, 133 | ): vscode.CompletionItem[] { 134 | if (!config("controllerAction.completion", true)) { 135 | return []; 136 | } 137 | 138 | if (result.currentParamIsArray()) { 139 | return []; 140 | } 141 | 142 | const indexMapping = { 143 | match: 2, 144 | }; 145 | 146 | // @ts-ignore 147 | const index = indexMapping[result.func()] ?? 1; 148 | 149 | if (!result.isParamIndex(index)) { 150 | return []; 151 | } 152 | 153 | return getControllers() 154 | .items.filter( 155 | (controller) => 156 | typeof controller === "string" && controller.length > 0, 157 | ) 158 | .map((controller: string) => { 159 | let completionItem = new vscode.CompletionItem( 160 | controller, 161 | vscode.CompletionItemKind.Enum, 162 | ); 163 | 164 | completionItem.range = document.getWordRangeAtPosition( 165 | position, 166 | wordMatchRegex, 167 | ); 168 | 169 | return completionItem; 170 | }); 171 | }, 172 | }; 173 | -------------------------------------------------------------------------------- /src/features/livewireComponent.ts: -------------------------------------------------------------------------------- 1 | import { getViews } from "@src/repositories/views"; 2 | import { config } from "@src/support/config"; 3 | import { projectPath } from "@src/support/project"; 4 | import * as vscode from "vscode"; 5 | import { LinkProvider } from ".."; 6 | 7 | export const linkProvider: LinkProvider = (doc: vscode.TextDocument) => { 8 | const links: vscode.DocumentLink[] = []; 9 | const text = doc.getText(); 10 | const lines = text.split("\n"); 11 | const views = getViews().items; 12 | 13 | lines.forEach((line, index) => { 14 | const match = line.match(/<\/?livewire:([^\s>]+)/); 15 | 16 | if (match && match.index !== undefined) { 17 | const componentName = match[1]; 18 | // Standard component 19 | const viewName = `livewire.${componentName}`; 20 | // Index component 21 | const view = views.find((v) => v.key === viewName); 22 | 23 | if (view) { 24 | links.push( 25 | new vscode.DocumentLink( 26 | new vscode.Range( 27 | new vscode.Position(index, match.index + 1), 28 | new vscode.Position( 29 | index, 30 | match.index + match[0].length, 31 | ), 32 | ), 33 | vscode.Uri.file(projectPath(view.path)), 34 | ), 35 | ); 36 | } 37 | } 38 | }); 39 | 40 | return Promise.resolve(links); 41 | }; 42 | 43 | export const completionProvider: vscode.CompletionItemProvider = { 44 | provideCompletionItems( 45 | doc: vscode.TextDocument, 46 | pos: vscode.Position, 47 | ): vscode.ProviderResult { 48 | if (!config("livewireComponent.completion", true)) { 49 | return undefined; 50 | } 51 | 52 | const componentPrefix = " view.key.startsWith(pathPrefix)) 66 | .map( 67 | (view) => 68 | new vscode.CompletionItem(view.key.replace(pathPrefix, "")), 69 | ); 70 | }, 71 | }; 72 | -------------------------------------------------------------------------------- /src/features/middleware.ts: -------------------------------------------------------------------------------- 1 | import { notFound } from "@src/diagnostic"; 2 | import AutocompleteResult from "@src/parser/AutocompleteResult"; 3 | import { getMiddleware } from "@src/repositories/middleware"; 4 | import { config } from "@src/support/config"; 5 | import { findHoverMatchesInDoc } from "@src/support/doc"; 6 | import { detectedRange, detectInDoc } from "@src/support/parser"; 7 | import { wordMatchRegex } from "@src/support/patterns"; 8 | import { projectPath } from "@src/support/project"; 9 | import { facade } from "@src/support/util"; 10 | import { AutocompleteParsingResult } from "@src/types"; 11 | import * as vscode from "vscode"; 12 | import { FeatureTag, HoverProvider, LinkProvider } from ".."; 13 | 14 | const toFind: FeatureTag = { 15 | class: facade("Route"), 16 | method: ["middleware", "withoutMiddleware"], 17 | }; 18 | 19 | const getName = (match: string) => { 20 | return match.split(":").shift() ?? ""; 21 | }; 22 | 23 | const processParam = ( 24 | param: AutocompleteParsingResult.ContextValue, 25 | cb: (value: AutocompleteParsingResult.StringValue) => T | null, 26 | ) => { 27 | if (param.type === "string") { 28 | return cb(param); 29 | } 30 | 31 | return (param as AutocompleteParsingResult.ArrayValue).children 32 | .map(({ value }) => { 33 | return value?.type === "string" ? cb(value) : null; 34 | }) 35 | .filter((i: T | null) => i !== null); 36 | }; 37 | 38 | export const linkProvider: LinkProvider = (doc: vscode.TextDocument) => { 39 | return detectInDoc( 40 | doc, 41 | toFind, 42 | getMiddleware, 43 | ({ param }) => { 44 | const routes = getMiddleware().items; 45 | 46 | return processParam(param, (value) => { 47 | const route = routes[getName(value.value)]; 48 | 49 | if (!route || !route.path) { 50 | return null; 51 | } 52 | 53 | return new vscode.DocumentLink( 54 | detectedRange(value), 55 | vscode.Uri.file(projectPath(route.path)).with({ 56 | fragment: `L${route.line}`, 57 | }), 58 | ); 59 | }); 60 | }, 61 | ["string", "array"], 62 | ); 63 | }; 64 | 65 | export const hoverProvider: HoverProvider = ( 66 | doc: vscode.TextDocument, 67 | pos: vscode.Position, 68 | ): vscode.ProviderResult => { 69 | return findHoverMatchesInDoc( 70 | doc, 71 | pos, 72 | toFind, 73 | getMiddleware, 74 | (match) => { 75 | const item = getMiddleware().items[getName(match)]; 76 | 77 | if (item?.path) { 78 | const text = [ 79 | `[${item.path}](${vscode.Uri.file( 80 | projectPath(item.path), 81 | ).with({ 82 | fragment: `L${item.line}`, 83 | })})`, 84 | ]; 85 | 86 | return new vscode.Hover( 87 | new vscode.MarkdownString(text.join("\n\n")), 88 | ); 89 | } 90 | 91 | if (item.groups.length === 0) { 92 | return null; 93 | } 94 | 95 | const text = item.groups.map((i) => 96 | i.path 97 | ? `[${i.path}](${vscode.Uri.file(projectPath(i.path)).with({ 98 | fragment: `L${i.line}`, 99 | })})` 100 | : i.class, 101 | ); 102 | 103 | return new vscode.Hover( 104 | new vscode.MarkdownString(text.join("\n\n")), 105 | ); 106 | }, 107 | ["string", "array"], 108 | ); 109 | }; 110 | 111 | export const diagnosticProvider = ( 112 | doc: vscode.TextDocument, 113 | ): Promise => { 114 | return detectInDoc( 115 | doc, 116 | toFind, 117 | getMiddleware, 118 | ({ param }) => { 119 | const routes = getMiddleware().items; 120 | 121 | return processParam(param, (value) => { 122 | const route = routes[getName(value.value)]; 123 | 124 | if (route) { 125 | return null; 126 | } 127 | 128 | return notFound( 129 | "Middleware", 130 | value.value, 131 | detectedRange(value), 132 | "middleware", 133 | ); 134 | }); 135 | }, 136 | ["string", "array"], 137 | ); 138 | }; 139 | 140 | export const completionProvider = { 141 | tags() { 142 | return toFind; 143 | }, 144 | 145 | provideCompletionItems( 146 | result: AutocompleteResult, 147 | document: vscode.TextDocument, 148 | position: vscode.Position, 149 | token: vscode.CancellationToken, 150 | context: vscode.CompletionContext, 151 | ): vscode.CompletionItem[] { 152 | if (!config("middleware.completion", true)) { 153 | return []; 154 | } 155 | 156 | return Object.entries(getMiddleware().items).map(([key, value]) => { 157 | let completionItem = new vscode.CompletionItem( 158 | key, 159 | vscode.CompletionItemKind.Enum, 160 | ); 161 | 162 | completionItem.detail = value.parameters ?? ""; 163 | 164 | completionItem.range = document.getWordRangeAtPosition( 165 | position, 166 | wordMatchRegex, 167 | ); 168 | 169 | return completionItem; 170 | }); 171 | }, 172 | }; 173 | -------------------------------------------------------------------------------- /src/features/mix.ts: -------------------------------------------------------------------------------- 1 | import { notFound } from "@src/diagnostic"; 2 | import AutocompleteResult from "@src/parser/AutocompleteResult"; 3 | import { getMixManifest } from "@src/repositories/mix"; 4 | import { config } from "@src/support/config"; 5 | import { findHoverMatchesInDoc } from "@src/support/doc"; 6 | import { detectedRange, detectInDoc } from "@src/support/parser"; 7 | import { wordMatchRegex } from "@src/support/patterns"; 8 | import { relativePath } from "@src/support/project"; 9 | import * as vscode from "vscode"; 10 | import { 11 | CompletionProvider, 12 | FeatureTag, 13 | HoverProvider, 14 | LinkProvider, 15 | } from ".."; 16 | 17 | const toFind: FeatureTag = [ 18 | { 19 | method: ["mix"], 20 | argumentIndex: 0, 21 | }, 22 | ]; 23 | 24 | export const linkProvider: LinkProvider = (doc: vscode.TextDocument) => { 25 | return detectInDoc( 26 | doc, 27 | toFind, 28 | getMixManifest, 29 | ({ param }) => { 30 | const mixItem = getMixManifest().items.find( 31 | (i) => i.key === param.value, 32 | ); 33 | 34 | if (!mixItem) { 35 | return null; 36 | } 37 | 38 | return new vscode.DocumentLink(detectedRange(param), mixItem.uri); 39 | }, 40 | ); 41 | }; 42 | 43 | export const hoverProvider: HoverProvider = ( 44 | doc: vscode.TextDocument, 45 | pos: vscode.Position, 46 | ): vscode.ProviderResult => { 47 | const items = getMixManifest().items; 48 | return findHoverMatchesInDoc(doc, pos, toFind, getMixManifest, (match) => { 49 | const item = items.find((item) => item.key === match); 50 | 51 | if (!item) { 52 | return null; 53 | } 54 | 55 | return new vscode.Hover( 56 | new vscode.MarkdownString( 57 | `[${relativePath(item.uri.path)}](${item.uri})`, 58 | ), 59 | ); 60 | }); 61 | }; 62 | 63 | export const diagnosticProvider = ( 64 | doc: vscode.TextDocument, 65 | ): Promise => { 66 | return detectInDoc( 67 | doc, 68 | toFind, 69 | getMixManifest, 70 | ({ param }) => { 71 | const item = getMixManifest().items.find( 72 | (item) => item.key === param.value, 73 | ); 74 | 75 | if (item) { 76 | return null; 77 | } 78 | 79 | return notFound( 80 | "Mix manifest item", 81 | param.value, 82 | detectedRange(param), 83 | "mix", 84 | ); 85 | }, 86 | ); 87 | }; 88 | 89 | export const completionProvider: CompletionProvider = { 90 | tags() { 91 | return toFind; 92 | }, 93 | 94 | provideCompletionItems( 95 | result: AutocompleteResult, 96 | document: vscode.TextDocument, 97 | position: vscode.Position, 98 | token: vscode.CancellationToken, 99 | context: vscode.CompletionContext, 100 | ): vscode.CompletionItem[] { 101 | if (!config("mix.completion", true)) { 102 | return []; 103 | } 104 | 105 | return getMixManifest().items.map((mix) => { 106 | let completeItem = new vscode.CompletionItem( 107 | mix.key, 108 | vscode.CompletionItemKind.Value, 109 | ); 110 | 111 | completeItem.range = document.getWordRangeAtPosition( 112 | position, 113 | wordMatchRegex, 114 | ); 115 | 116 | return completeItem; 117 | }); 118 | }, 119 | }; 120 | -------------------------------------------------------------------------------- /src/features/paths.ts: -------------------------------------------------------------------------------- 1 | import { getPaths } from "@src/repositories/paths"; 2 | import { detectedRange, detectInDoc } from "@src/support/parser"; 3 | import fs from "fs"; 4 | import * as vscode from "vscode"; 5 | import { FeatureTag, LinkProvider } from ".."; 6 | 7 | const toFind: FeatureTag = { 8 | method: [ 9 | "base_path", 10 | "resource_path", 11 | "config_path", 12 | "app_path", 13 | "database_path", 14 | "lang_path", 15 | "public_path", 16 | "storage_path", 17 | ], 18 | argumentIndex: 0, 19 | }; 20 | 21 | export const linkProvider: LinkProvider = (doc: vscode.TextDocument) => { 22 | return detectInDoc( 23 | doc, 24 | toFind, 25 | getPaths, 26 | ({ param, item }) => { 27 | const basePath = getPaths().items.find( 28 | // @ts-ignore 29 | (asset) => asset.key === item.methodName, 30 | ); 31 | 32 | if (!basePath) { 33 | return null; 34 | } 35 | 36 | const path = param.value.startsWith("/") 37 | ? param.value 38 | : `/${param.value}`; 39 | 40 | const fullPath = basePath.path + path; 41 | 42 | const stat = fs.lstatSync(fullPath, { 43 | throwIfNoEntry: false, 44 | }); 45 | 46 | if (!stat?.isFile()) { 47 | return null; 48 | } 49 | 50 | return new vscode.DocumentLink( 51 | detectedRange(param), 52 | vscode.Uri.file(fullPath), 53 | ); 54 | }, 55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /src/features/storage.ts: -------------------------------------------------------------------------------- 1 | import { notFound } from "@src/diagnostic"; 2 | import AutocompleteResult from "@src/parser/AutocompleteResult"; 3 | import { getConfigs } from "@src/repositories/configs"; 4 | import { getPaths } from "@src/repositories/paths"; 5 | import { config } from "@src/support/config"; 6 | import { detectedRange, detectInDoc } from "@src/support/parser"; 7 | import { wordMatchRegex } from "@src/support/patterns"; 8 | import { projectPath } from "@src/support/project"; 9 | import { facade } from "@src/support/util"; 10 | import * as vscode from "vscode"; 11 | import { CompletionProvider, FeatureTag, LinkProvider } from ".."; 12 | 13 | const toFind: FeatureTag = { 14 | class: facade("Storage"), 15 | method: ["disk", "fake", "persistentFake", "forgetDisk"], 16 | argumentIndex: 0, 17 | }; 18 | 19 | export const linkProvider: LinkProvider = (doc: vscode.TextDocument) => { 20 | return detectInDoc( 21 | doc, 22 | toFind, 23 | getPaths, 24 | ({ param, item }) => { 25 | const configItem = getConfigs().items.find( 26 | (c) => c.name === `filesystems.disks.${param.value}`, 27 | ); 28 | 29 | if (!configItem || !configItem.file) { 30 | return null; 31 | } 32 | 33 | return new vscode.DocumentLink( 34 | detectedRange(param), 35 | vscode.Uri.file(projectPath(configItem.file)).with({ 36 | fragment: `L${configItem.line}`, 37 | }), 38 | ); 39 | }, 40 | ); 41 | }; 42 | 43 | export const diagnosticProvider = ( 44 | doc: vscode.TextDocument, 45 | ): Promise => { 46 | return detectInDoc( 47 | doc, 48 | toFind, 49 | getConfigs, 50 | ({ param, item, index }) => { 51 | const config = getConfigs().items.find( 52 | (c) => c.name === `filesystems.disks.${param.value}`, 53 | ); 54 | 55 | if (config) { 56 | return null; 57 | } 58 | 59 | return notFound( 60 | "Storage Disk", 61 | param.value, 62 | detectedRange(param), 63 | "storage_disk", 64 | ); 65 | }, 66 | ); 67 | }; 68 | 69 | export const completionProvider: CompletionProvider = { 70 | tags() { 71 | return toFind; 72 | }, 73 | 74 | provideCompletionItems( 75 | result: AutocompleteResult, 76 | document: vscode.TextDocument, 77 | position: vscode.Position, 78 | token: vscode.CancellationToken, 79 | context: vscode.CompletionContext, 80 | ): vscode.CompletionItem[] { 81 | if (!config("storage.completion", true)) { 82 | return []; 83 | } 84 | 85 | return getConfigs() 86 | .items.filter((config) => { 87 | return ( 88 | config.name.startsWith("filesystems.disks.") && 89 | config.name.split(".").length === 3 90 | ); 91 | }) 92 | .map((config) => { 93 | const completeItem = new vscode.CompletionItem( 94 | config.name.replace("filesystems.disks.", ""), 95 | vscode.CompletionItemKind.Value, 96 | ); 97 | 98 | completeItem.range = document.getWordRangeAtPosition( 99 | position, 100 | wordMatchRegex, 101 | ); 102 | 103 | return completeItem; 104 | }); 105 | }, 106 | }; 107 | -------------------------------------------------------------------------------- /src/hover/HoverProvider.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import { hoverProvider as appBinding } from "@src/features/appBinding"; 4 | import { hoverProvider as auth } from "@src/features/auth"; 5 | import { hoverProvider as bladeComponent } from "@src/features/bladeComponent"; 6 | import { hoverProvider as config } from "@src/features/config"; 7 | import { hoverProvider as env } from "@src/features/env"; 8 | import { hoverProvider as inertia } from "@src/features/inertia"; 9 | import { hoverProvider as middleware } from "@src/features/middleware"; 10 | import { hoverProvider as mix } from "@src/features/mix"; 11 | import { hoverProvider as route } from "@src/features/route"; 12 | import { hoverProvider as translation } from "@src/features/translation"; 13 | import { hoverProvider as view } from "@src/features/view"; 14 | import { config as getConfig } from "@src/support/config"; 15 | import { GeneratedConfigKey } from "@src/support/generated-config"; 16 | import { 17 | Hover, 18 | HoverProvider, 19 | Position, 20 | ProviderResult, 21 | TextDocument, 22 | } from "vscode"; 23 | import { HoverProvider as ProviderFunc } from ".."; 24 | 25 | const allProviders: Partial> = { 26 | "appBinding.hover": appBinding, 27 | "auth.hover": auth, 28 | "config.hover": config, 29 | "env.hover": env, 30 | "inertia.hover": inertia, 31 | "middleware.hover": middleware, 32 | "mix.hover": mix, 33 | "route.hover": route, 34 | "translation.hover": translation, 35 | "view.hover": view, 36 | "bladeComponent.hover": bladeComponent, 37 | }; 38 | 39 | export const hoverProviders: HoverProvider[] = Object.entries(allProviders).map( 40 | ([configKey, provider]) => ({ 41 | provideHover(doc: TextDocument, pos: Position): ProviderResult { 42 | if (!getConfig(configKey as GeneratedConfigKey, true)) { 43 | return null; 44 | } 45 | 46 | return provider(doc, pos); 47 | }, 48 | }), 49 | ); 50 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import AutocompleteResult from "./parser/AutocompleteResult"; 3 | 4 | type CodeActionProviderFunction = ( 5 | diagnostic: vscode.Diagnostic, 6 | document: vscode.TextDocument, 7 | range: vscode.Range | vscode.Selection, 8 | token: vscode.CancellationToken, 9 | ) => Promise; 10 | 11 | interface Config { 12 | name: string; 13 | value: string; 14 | file: string | null; 15 | line: string | null; 16 | } 17 | 18 | interface CompletionProvider { 19 | tags(): FeatureTag; 20 | customCheck?(result: AutocompleteResult, document: string): any; 21 | provideCompletionItems( 22 | result: AutocompleteResult, 23 | document: vscode.TextDocument, 24 | position: vscode.Position, 25 | token: vscode.CancellationToken, 26 | context: vscode.CompletionContext, 27 | ): vscode.CompletionItem[]; 28 | } 29 | 30 | interface View { 31 | name: string; 32 | path: string; 33 | } 34 | 35 | type FeatureTag = FeatureTagParam | FeatureTagParam[]; 36 | 37 | type ValidDetectParamTypes = "string" | "array"; 38 | 39 | type HoverProvider = ( 40 | doc: vscode.TextDocument, 41 | pos: vscode.Position, 42 | ) => vscode.ProviderResult; 43 | 44 | type LinkProvider = ( 45 | doc: vscode.TextDocument, 46 | ) => Promise<(vscode.DocumentLink | null)[]>; 47 | 48 | interface FeatureTagParam { 49 | class?: string | string[] | null; 50 | method?: string | string[] | null; 51 | argumentName?: string | string[]; 52 | classDefinition?: string; 53 | methodDefinition?: string; 54 | classExtends?: string; 55 | classImplements?: string; 56 | argumentIndex?: number | number[]; 57 | } 58 | 59 | declare namespace Eloquent { 60 | interface Result { 61 | models: Models; 62 | builderMethods: BuilderMethod[]; 63 | } 64 | 65 | interface BuilderMethod { 66 | name: string; 67 | parameters: string[]; 68 | return: string | null; 69 | } 70 | 71 | interface Models { 72 | [key: string]: Model; 73 | } 74 | 75 | interface Model { 76 | class: string; 77 | database: string; 78 | table: string; 79 | policy: string | null; 80 | attributes: Attribute[]; 81 | relations: Relation[]; 82 | events: Event[]; 83 | observers: Observer[]; 84 | scopes: string[]; 85 | extends: string | null; 86 | } 87 | 88 | interface Attribute { 89 | name: string; 90 | type: string; 91 | increments: boolean; 92 | nullable: boolean; 93 | default: string | null; 94 | unique: boolean; 95 | fillable: boolean; 96 | hidden: boolean; 97 | appended: null; 98 | cast: string | null; 99 | title_case: string; 100 | documented: boolean; 101 | } 102 | 103 | interface Relation { 104 | name: string; 105 | type: string; 106 | related: string; 107 | } 108 | 109 | interface Event { 110 | event: string; 111 | class: string; 112 | } 113 | 114 | interface Observer { 115 | event: string; 116 | observer: string[]; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/link/LinkProvider.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import { linkProvider as appBinding } from "@src/features/appBinding"; 4 | import { linkProvider as asset } from "@src/features/asset"; 5 | import { linkProvider as auth } from "@src/features/auth"; 6 | import { linkProvider as bladeComponent } from "@src/features/bladeComponent"; 7 | import { linkProvider as config } from "@src/features/config"; 8 | import { linkProvider as controllerAction } from "@src/features/controllerAction"; 9 | import { linkProvider as env } from "@src/features/env"; 10 | import { linkProvider as inertia } from "@src/features/inertia"; 11 | import { linkProvider as livewireComponent } from "@src/features/livewireComponent"; 12 | import { linkProvider as middleware } from "@src/features/middleware"; 13 | import { linkProvider as mix } from "@src/features/mix"; 14 | import { linkProvider as paths } from "@src/features/paths"; 15 | import { linkProvider as route } from "@src/features/route"; 16 | import { linkProvider as storage } from "@src/features/storage"; 17 | import { linkProvider as translation } from "@src/features/translation"; 18 | import { linkProvider as view } from "@src/features/view"; 19 | import { LinkProvider as LinkProviderType } from "@src/index"; 20 | import { config as getConfig } from "@src/support/config"; 21 | import { GeneratedConfigKey } from "@src/support/generated-config"; 22 | import { 23 | DocumentLink, 24 | DocumentLinkProvider, 25 | ProviderResult, 26 | TextDocument, 27 | } from "vscode"; 28 | 29 | const allProviders: Partial> = { 30 | "appBinding.link": appBinding, 31 | "asset.link": asset, 32 | "auth.link": auth, 33 | "bladeComponent.link": bladeComponent, 34 | "config.link": config, 35 | "controllerAction.link": controllerAction, 36 | "env.link": env, 37 | "inertia.link": inertia, 38 | "livewireComponent.link": livewireComponent, 39 | "middleware.link": middleware, 40 | "mix.link": mix, 41 | "paths.link": paths, 42 | "route.link": route, 43 | "storage.link": storage, 44 | "translation.link": translation, 45 | "view.link": view, 46 | }; 47 | 48 | export const linkProviders: DocumentLinkProvider[] = Object.entries( 49 | allProviders, 50 | ).map(([configKey, provider]) => ({ 51 | provideDocumentLinks(doc: TextDocument): ProviderResult { 52 | if (!getConfig(configKey as GeneratedConfigKey, true)) { 53 | return []; 54 | } 55 | 56 | return provider(doc).then((result) => { 57 | return result.flat().filter((i) => i !== null); 58 | }); 59 | }, 60 | })); 61 | -------------------------------------------------------------------------------- /src/parser/AutocompleteResult.ts: -------------------------------------------------------------------------------- 1 | import { contract, facade } from "@src/support/util"; 2 | import { AutocompleteParsingResult } from "@src/types"; 3 | 4 | export default class AutocompleteResult { 5 | public result: AutocompleteParsingResult.ContextValue; 6 | 7 | private additionalInfo: Record = {}; 8 | 9 | constructor(result: AutocompleteParsingResult.ContextValue) { 10 | this.result = result; 11 | } 12 | 13 | public currentParamIsArray(): boolean { 14 | const currentArg = this.param(); 15 | 16 | if (currentArg === null) { 17 | return false; 18 | } 19 | 20 | return currentArg?.type === "array"; 21 | } 22 | 23 | public currentParamArrayKeys(): string[] { 24 | const param = this.param(); 25 | 26 | if (typeof param === "undefined" || param.type !== "array") { 27 | return []; 28 | } 29 | 30 | return (param as AutocompleteParsingResult.ArrayValue).children.map( 31 | (child) => child.key.value, 32 | ); 33 | } 34 | 35 | public fillingInArrayKey(): boolean { 36 | return this.param()?.autocompletingKey ?? false; 37 | } 38 | 39 | public fillingInArrayValue(): boolean { 40 | return this.param()?.autocompletingValue ?? false; 41 | } 42 | 43 | public class() { 44 | // @ts-ignore 45 | return this.result.className ?? null; 46 | } 47 | 48 | public isClass(className: string | string[]) { 49 | if (Array.isArray(className)) { 50 | return className.includes(this.class()); 51 | } 52 | 53 | return this.class() === className; 54 | } 55 | 56 | public isFacade(className: string) { 57 | return this.isClass(facade(className)); 58 | } 59 | 60 | public isContract(classNames: string | string[]) { 61 | classNames = Array.isArray(classNames) ? classNames : [classNames]; 62 | 63 | return classNames.some((className: string) => 64 | this.isClass(contract(className)), 65 | ); 66 | } 67 | 68 | public func() { 69 | // @ts-ignore 70 | return this.result.methodName ?? null; 71 | } 72 | 73 | public addInfo(key: string, value: any) { 74 | this.additionalInfo[key] = value; 75 | } 76 | 77 | public getInfo(key: string) { 78 | return this.additionalInfo[key]; 79 | } 80 | 81 | public loop( 82 | cb: (context: AutocompleteParsingResult.ContextValue) => boolean | void, 83 | ) { 84 | let context = this.result; 85 | let shouldContinue = true; 86 | 87 | while (context.parent !== null && shouldContinue) { 88 | shouldContinue = cb(context) ?? true; 89 | 90 | context = context.parent; 91 | } 92 | } 93 | 94 | public isFunc(funcs: string | string[]) { 95 | funcs = Array.isArray(funcs) ? funcs : [funcs]; 96 | 97 | return funcs.some((func: string) => this.func() === func); 98 | } 99 | 100 | public param(index?: number) { 101 | index = index ?? this.paramIndex(); 102 | 103 | if (index === null || typeof index === "undefined") { 104 | return null; 105 | } 106 | 107 | // @ts-ignore 108 | return this.result.arguments?.children[index]?.children[0]; 109 | } 110 | 111 | public paramIndex() { 112 | // @ts-ignore 113 | return this.result.arguments?.autocompletingIndex ?? null; 114 | } 115 | 116 | public argumentName() { 117 | return this.param()?.name ?? null; 118 | } 119 | 120 | public isArgumentNamed(name: string) { 121 | return this.argumentName() === name; 122 | } 123 | 124 | public isParamIndex(index: number) { 125 | return this.paramIndex() === index; 126 | } 127 | 128 | public paramCount() { 129 | // @ts-ignore 130 | return this.result.arguments?.children.length ?? 0; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/repositories/appBinding.ts: -------------------------------------------------------------------------------- 1 | import { repository } from "."; 2 | import { runInLaravel, template } from "../support/php"; 3 | 4 | type AppBindingItem = { 5 | [key: string]: { 6 | class: string; 7 | path: string; 8 | line: number; 9 | }; 10 | }; 11 | 12 | export const getAppBindings = repository({ 13 | load: () => { 14 | return runInLaravel(template("app"), "App Bindings"); 15 | }, 16 | pattern: "app/Providers/{,*,**/*}.php", 17 | itemsDefault: {}, 18 | }); 19 | -------------------------------------------------------------------------------- /src/repositories/asset.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | import * as vscode from "vscode"; 4 | import { repository } from "."; 5 | import { projectPath, relativePath } from "../support/project"; 6 | 7 | interface Item { 8 | path: string; 9 | uri: vscode.Uri; 10 | } 11 | 12 | const getFilesInDirectory = (dir: string, depth: number = 0): Item[] => { 13 | let dirFullPath = projectPath(dir); 14 | 15 | if (!fs.existsSync(dirFullPath)) { 16 | return []; 17 | } 18 | 19 | if (depth > 10) { 20 | return []; 21 | } 22 | 23 | return fs 24 | .readdirSync(dirFullPath) 25 | .map((filePath) => { 26 | let fullFilePath = `${dirFullPath}/${filePath}`; 27 | let shortFilePath = `${dir}/${filePath}`; 28 | let stat = fs.lstatSync(fullFilePath); 29 | 30 | if (stat.isDirectory()) { 31 | return getFilesInDirectory(shortFilePath, depth + 1); 32 | } 33 | 34 | if ( 35 | stat.isFile() && 36 | filePath[0] !== ".." && 37 | filePath.endsWith(".php") === false 38 | ) { 39 | return [ 40 | { 41 | path: relativePath( 42 | fullFilePath.replace(/[\/\\]/g, path.sep), 43 | ) 44 | .replace("public" + path.sep, "") 45 | .replaceAll(path.sep, "/"), 46 | uri: vscode.Uri.file(fullFilePath), 47 | }, 48 | ]; 49 | } 50 | 51 | return []; 52 | }) 53 | .flat(); 54 | }; 55 | 56 | export const getAssets = repository({ 57 | load: () => 58 | new Promise((resolve, reject) => { 59 | try { 60 | resolve(getFilesInDirectory("public")); 61 | } catch (exception) { 62 | reject(exception); 63 | } 64 | }), 65 | pattern: "public/**/*", 66 | itemsDefault: [], 67 | reloadOnComposerChanges: false, 68 | }); 69 | -------------------------------------------------------------------------------- /src/repositories/auth.ts: -------------------------------------------------------------------------------- 1 | import { internalVendorPath } from "@src/support/project"; 2 | import fs from "fs"; 3 | import { repository } from "."; 4 | import { runInLaravel, template } from "./../support/php"; 5 | 6 | type AuthItems = { 7 | authenticatable: string | null; 8 | policies: { 9 | [key: string]: AuthItem[]; 10 | }; 11 | }; 12 | 13 | export type AuthItem = { 14 | policy: string | null; 15 | uri: string; 16 | line: number; 17 | model: string | null; 18 | }; 19 | 20 | const writeAuthBlocks = (authenticatable: string | null) => { 21 | if (!authenticatable) { 22 | return; 23 | } 24 | 25 | const blocks = [ 26 | { 27 | file: "_auth.php", 28 | content: ` 29 | { 104 | fs.writeFileSync(internalVendorPath(block.file), block.content.trim()); 105 | }); 106 | }; 107 | 108 | const load = () => { 109 | return runInLaravel(template("auth"), "Auth Data").then( 110 | (result) => { 111 | writeAuthBlocks(result.authenticatable); 112 | 113 | return result; 114 | }, 115 | ); 116 | }; 117 | 118 | export const getPolicies = repository({ 119 | load, 120 | pattern: "app/Providers/{,*,**/*}.php", 121 | itemsDefault: { 122 | authenticatable: null, 123 | policies: {}, 124 | }, 125 | }); 126 | -------------------------------------------------------------------------------- /src/repositories/bladeComponents.ts: -------------------------------------------------------------------------------- 1 | import { inAppDirs } from "@src/support/fileWatcher"; 2 | import { runInLaravel, template } from "@src/support/php"; 3 | import { repository } from "."; 4 | 5 | export interface BladeComponents { 6 | components: { 7 | [key: string]: { 8 | paths: string[]; 9 | isVendor: boolean; 10 | props: { 11 | name: string; 12 | type: string; 13 | default: string | null; 14 | }[]; 15 | }; 16 | }; 17 | prefixes: string[]; 18 | } 19 | 20 | const load = () => { 21 | return runInLaravel(template("bladeComponents")); 22 | }; 23 | 24 | export const getBladeComponents = repository({ 25 | load, 26 | pattern: [ 27 | inAppDirs("{,**/}{view,views}/{*,**/*}"), 28 | "app/View/Components/{,*,**/*}.php", 29 | ], 30 | itemsDefault: { 31 | components: {}, 32 | prefixes: [], 33 | }, 34 | fileWatcherEvents: ["create", "delete"], 35 | }); 36 | -------------------------------------------------------------------------------- /src/repositories/configs.ts: -------------------------------------------------------------------------------- 1 | import { repository } from "."; 2 | import { Config } from ".."; 3 | import { runInLaravel, template } from "../support/php"; 4 | 5 | export const getConfigs = repository({ 6 | load: () => { 7 | return runInLaravel(template("configs"), "Configs").then( 8 | (result) => 9 | result.map((item) => { 10 | return { 11 | name: item.name, 12 | value: item.value, 13 | file: item.file, 14 | line: item.line, 15 | }; 16 | }), 17 | ); 18 | }, 19 | pattern: ["config/{,*,**/*}.php", ".env"], 20 | itemsDefault: [], 21 | }); 22 | -------------------------------------------------------------------------------- /src/repositories/controllers.ts: -------------------------------------------------------------------------------- 1 | import { inAppDirs } from "@src/support/fileWatcher"; 2 | import * as fs from "fs"; 3 | import { repository } from "."; 4 | import { projectPath } from "../support/project"; 5 | 6 | const load = (): string[] => { 7 | return collectControllers(projectPath("app/Http/Controllers")).map( 8 | (controller) => controller.replace(/@__invoke/, ""), 9 | ); 10 | }; 11 | 12 | const collectControllers = (path: string): string[] => { 13 | let controllers = new Set(); 14 | 15 | if (path.substring(-1) !== "/" && path.substring(-1) !== "\\") { 16 | path += "/"; 17 | } 18 | 19 | if (!fs.existsSync(path) || !fs.lstatSync(path).isDirectory()) { 20 | return [...controllers]; 21 | } 22 | 23 | fs.readdirSync(path).forEach((file) => { 24 | const fullPath = path + file; 25 | 26 | if (fs.lstatSync(fullPath).isDirectory()) { 27 | collectControllers(fullPath + "/").forEach((controller) => { 28 | controllers.add(controller); 29 | }); 30 | 31 | return; 32 | } 33 | 34 | if (!file.includes(".php")) { 35 | return; 36 | } 37 | 38 | const controllerContent = fs.readFileSync(fullPath, "utf8"); 39 | 40 | if (controllerContent.length > 50_000) { 41 | // TODO: Hm, yeah? 42 | return; 43 | } 44 | 45 | let match = /class\s+([A-Za-z0-9_]+)\s+extends\s+.+/g.exec( 46 | controllerContent, 47 | ); 48 | 49 | const matchNamespace = 50 | /namespace .+\\Http\\Controllers\\?([A-Za-z0-9_]*)/g.exec( 51 | controllerContent, 52 | ); 53 | 54 | if (match === null || !matchNamespace) { 55 | return; 56 | } 57 | 58 | const functionRegex = /public\s+function\s+([A-Za-z0-9_]+)\(.*\)/g; 59 | 60 | let className = match[1]; 61 | let namespace = matchNamespace[1]; 62 | 63 | while ( 64 | (match = functionRegex.exec(controllerContent)) !== null && 65 | match[1] !== "__construct" 66 | ) { 67 | if (namespace.length > 0) { 68 | controllers.add(`${namespace}\\${className}@${match[1]}`); 69 | } 70 | 71 | controllers.add(`${className}@${match[1]}`); 72 | } 73 | }); 74 | 75 | return [...controllers]; 76 | }; 77 | 78 | export const getControllers = repository({ 79 | load: () => { 80 | return new Promise((resolve) => { 81 | resolve(load()); 82 | }); 83 | }, 84 | pattern: inAppDirs("{,**/}{Controllers}{.php,/*.php,/**/*.php}"), 85 | itemsDefault: [], 86 | }); 87 | -------------------------------------------------------------------------------- /src/repositories/customBladeDirectives.ts: -------------------------------------------------------------------------------- 1 | import { repository } from "."; 2 | import { runInLaravel, template } from "../support/php"; 3 | 4 | interface CustomDirectiveItem { 5 | name: string; 6 | hasParams: boolean; 7 | } 8 | 9 | export const getCustomBladeDirectives = repository({ 10 | load: () => 11 | runInLaravel( 12 | template("bladeDirectives"), 13 | "Custom Blade Directives", 14 | ), 15 | pattern: "app/{,*,**/*}Provider.php", 16 | itemsDefault: [], 17 | }); 18 | -------------------------------------------------------------------------------- /src/repositories/env-example.ts: -------------------------------------------------------------------------------- 1 | import { projectPathExists, readFileInProject } from "@src/support/project"; 2 | import { repository } from "."; 3 | 4 | const filename = ".env.example"; 5 | 6 | interface EnvItem { 7 | [key: string]: { 8 | value: string; 9 | lineNumber: number; 10 | }; 11 | } 12 | 13 | const load = () => { 14 | let items: EnvItem = {}; 15 | 16 | if (!projectPathExists(filename)) { 17 | return items; 18 | } 19 | 20 | readFileInProject(filename) 21 | .split("\n") 22 | .map((env, index) => ({ 23 | line: env.trim(), 24 | index, 25 | })) 26 | .filter((env) => !env.line.startsWith("#")) 27 | .map((env) => ({ 28 | line: env.line.split("=").map((env) => env.trim()), 29 | index: env.index, 30 | })) 31 | .filter((env) => env.line.length === 2) 32 | .forEach((env) => { 33 | const [key, value] = env.line; 34 | 35 | items[key] = { 36 | value, 37 | lineNumber: env.index + 1, 38 | }; 39 | }); 40 | 41 | return items; 42 | }; 43 | 44 | export const getEnvExample = repository({ 45 | load: () => 46 | new Promise((resolve, reject) => { 47 | try { 48 | resolve(load()); 49 | } catch (error) { 50 | reject(error); 51 | } 52 | }), 53 | pattern: filename, 54 | itemsDefault: {}, 55 | reloadOnComposerChanges: false, 56 | }); 57 | -------------------------------------------------------------------------------- /src/repositories/env.ts: -------------------------------------------------------------------------------- 1 | import { repository } from "."; 2 | import { projectPathExists, readFileInProject } from "../support/project"; 3 | 4 | interface EnvItem { 5 | [key: string]: { 6 | value: string; 7 | lineNumber: number; 8 | }; 9 | } 10 | 11 | const getKeyValue = (str: string) => { 12 | const parts = str.split("=").map((env) => env.trim()); 13 | 14 | return [parts[0], parts.slice(1).join("=")]; 15 | }; 16 | 17 | const load = () => { 18 | let items: EnvItem = {}; 19 | 20 | if (!projectPathExists(".env")) { 21 | return items; 22 | } 23 | 24 | readFileInProject(".env") 25 | .split("\n") 26 | .map((env, index) => ({ 27 | line: env.trim(), 28 | index, 29 | })) 30 | .filter((env) => !env.line.startsWith("#")) 31 | .map((env) => ({ 32 | line: getKeyValue(env.line), 33 | index: env.index, 34 | })) 35 | .filter((env) => env.line.length === 2) 36 | .forEach((env) => { 37 | const [key, value] = env.line; 38 | 39 | items[key] = { 40 | value, 41 | lineNumber: env.index + 1, 42 | }; 43 | }); 44 | 45 | return items; 46 | }; 47 | 48 | export const getEnv = repository({ 49 | load: () => 50 | new Promise((resolve, reject) => { 51 | try { 52 | resolve(load()); 53 | } catch (error) { 54 | reject(error); 55 | } 56 | }), 57 | pattern: ".env", 58 | itemsDefault: {}, 59 | reloadOnComposerChanges: false, 60 | }); 61 | -------------------------------------------------------------------------------- /src/repositories/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type FileEvent, 3 | WatcherPattern, 4 | defaultFileEvents, 5 | loadAndWatch, 6 | } from "../support/fileWatcher"; 7 | 8 | export const repository = ({ 9 | load, 10 | pattern, 11 | itemsDefault, 12 | fileWatcherEvents = defaultFileEvents, 13 | reloadOnComposerChanges = true, 14 | }: { 15 | load: () => Promise; 16 | pattern: WatcherPattern; 17 | itemsDefault: T; 18 | fileWatcherEvents?: FileEvent[]; 19 | reloadOnComposerChanges?: boolean; 20 | }) => { 21 | let items: T = itemsDefault; 22 | let loaded = false; 23 | 24 | loadAndWatch( 25 | () => { 26 | load() 27 | .then((result) => { 28 | if (typeof result === "undefined") { 29 | throw new Error("Failed to load items"); 30 | } 31 | 32 | items = result; 33 | loaded = true; 34 | }) 35 | .catch((e) => { 36 | console.error(e); 37 | }); 38 | }, 39 | pattern, 40 | fileWatcherEvents, 41 | reloadOnComposerChanges, 42 | ); 43 | 44 | const whenLoaded = async (callback: (items: T) => any, maxTries = 20) => { 45 | let tries = 0; 46 | 47 | while (!loaded && tries < maxTries) { 48 | tries++; 49 | await new Promise((resolve) => setTimeout(resolve, 100)); 50 | } 51 | 52 | if (loaded) { 53 | return callback(items); 54 | } 55 | }; 56 | 57 | return () => ({ 58 | loaded, 59 | items, 60 | whenLoaded, 61 | }); 62 | }; 63 | -------------------------------------------------------------------------------- /src/repositories/inertia.ts: -------------------------------------------------------------------------------- 1 | import { runInLaravel, template } from "@src/support/php"; 2 | import { projectPath, relativePath } from "@src/support/project"; 3 | import { waitForValue } from "@src/support/util"; 4 | import * as fs from "fs"; 5 | import * as sysPath from "path"; 6 | import { repository } from "."; 7 | import { View } from ".."; 8 | 9 | interface ViewItem { 10 | [key: string]: View; 11 | } 12 | 13 | export let inertiaPagePaths: string[] | null = null; 14 | export let inertiaPageExtensions: string[] | null = null; 15 | 16 | const load = (pagePaths: string[], validExtensions: string[]) => { 17 | inertiaPagePaths = pagePaths; 18 | inertiaPageExtensions = validExtensions; 19 | 20 | let views: ViewItem = {}; 21 | 22 | pagePaths = pagePaths.length > 0 ? pagePaths : ["resources/js/Pages"]; 23 | 24 | pagePaths 25 | .map((path) => sysPath.sep + relativePath(path)) 26 | .forEach((path) => { 27 | collectViews(projectPath(path), path, validExtensions).forEach( 28 | (view) => { 29 | views[view.name] = view; 30 | }, 31 | ); 32 | }); 33 | 34 | return views; 35 | }; 36 | 37 | const collectViews = ( 38 | path: string, 39 | basePath: string, 40 | validExtensions: string[], 41 | ): View[] => { 42 | if (path.substring(-1) === sysPath.sep) { 43 | path = path.substring(0, path.length - 1); 44 | } 45 | 46 | if (!fs.existsSync(path) || !fs.lstatSync(path).isDirectory()) { 47 | return []; 48 | } 49 | 50 | return fs 51 | .readdirSync(path) 52 | .map((file: string) => { 53 | if (fs.lstatSync(sysPath.join(path, file)).isDirectory()) { 54 | return collectViews( 55 | sysPath.join(path, file), 56 | basePath, 57 | validExtensions, 58 | ); 59 | } 60 | 61 | const parts = file.split("."); 62 | const extension = parts.pop(); 63 | 64 | if (!validExtensions.includes(extension ?? "")) { 65 | return []; 66 | } 67 | 68 | const name = parts.join("."); 69 | 70 | return { 71 | name: relativePath(sysPath.join(path, name)) 72 | .replace(basePath.substring(1) + sysPath.sep, "") 73 | .replaceAll(sysPath.sep, "/"), 74 | path: relativePath(sysPath.join(path, file)), 75 | }; 76 | }) 77 | .flat(); 78 | }; 79 | 80 | export const getInertiaViews = repository({ 81 | load: () => 82 | runInLaravel<{ 83 | page_paths?: string[]; 84 | page_extensions?: string[]; 85 | }>(template("inertia")).then((result) => { 86 | return load( 87 | result?.page_paths ?? [], 88 | result?.page_extensions ?? [], 89 | ); 90 | }), 91 | pattern: () => 92 | waitForValue(() => inertiaPagePaths).then((pagePaths) => { 93 | if (pagePaths === null || pagePaths.length === 0) { 94 | return null; 95 | } 96 | 97 | return `{${pagePaths.join(",")}}/{*,**/*}`; 98 | }), 99 | itemsDefault: {}, 100 | fileWatcherEvents: ["create", "delete"], 101 | }); 102 | -------------------------------------------------------------------------------- /src/repositories/middleware.ts: -------------------------------------------------------------------------------- 1 | import { repository } from "."; 2 | import { runInLaravel, template } from "../support/php"; 3 | 4 | interface MiddlewareItem { 5 | [key: string]: { 6 | class: string | null; 7 | path: string | null; 8 | line: number | null; 9 | parameters: string | null; 10 | groups: { 11 | class: string; 12 | path: string | null; 13 | line: number | null; 14 | }[]; 15 | }; 16 | } 17 | 18 | export const getMiddleware = repository({ 19 | load: () => { 20 | return runInLaravel( 21 | template("middleware"), 22 | "Middlewares", 23 | ).then((result) => { 24 | const items: MiddlewareItem = {}; 25 | 26 | for (let key in result) { 27 | items[key] = { 28 | class: result[key].class, 29 | path: result[key].path, 30 | line: result[key].line, 31 | parameters: result[key].parameters, 32 | groups: result[key].groups.map((group) => ({ 33 | class: group.class, 34 | path: group.path, 35 | line: group.line, 36 | })), 37 | }; 38 | } 39 | 40 | return items; 41 | }); 42 | }, 43 | pattern: [ 44 | "app/Http/Kernel.php", 45 | "bootstrap/app.php" 46 | ], 47 | itemsDefault: {}, 48 | fileWatcherEvents: ["change"], 49 | }); 50 | -------------------------------------------------------------------------------- /src/repositories/mix.ts: -------------------------------------------------------------------------------- 1 | import { projectPathExists, readFileInProject } from "@src/support/project"; 2 | import * as sysPath from "path"; 3 | import { Uri } from "vscode"; 4 | import { repository } from "."; 5 | 6 | interface MixManifestItem { 7 | key: string; 8 | value: string; 9 | uri: Uri; 10 | } 11 | 12 | const load = () => { 13 | const path = "public/mix-manifest.json"; 14 | 15 | if (!projectPathExists(path)) { 16 | return []; 17 | } 18 | 19 | const results: { 20 | [key: string]: string; 21 | } = JSON.parse(readFileInProject(path)); 22 | 23 | return Object.entries(results).map(([key, value]) => ({ 24 | key: key.replace(sysPath.sep, ""), 25 | value: value.replace(sysPath.sep, ""), 26 | uri: Uri.file(sysPath.join("public", value)), 27 | })); 28 | }; 29 | 30 | export const getMixManifest = repository({ 31 | load: () => { 32 | return new Promise((resolve, reject) => { 33 | try { 34 | resolve(load()); 35 | } catch (error) { 36 | reject(error); 37 | } 38 | }); 39 | }, 40 | pattern: "public/mix-manifest.json", 41 | itemsDefault: [], 42 | reloadOnComposerChanges: false, 43 | }); 44 | -------------------------------------------------------------------------------- /src/repositories/models.ts: -------------------------------------------------------------------------------- 1 | import { repository } from "."; 2 | import { Eloquent } from ".."; 3 | import { writeEloquentDocBlocks } from "../support/docblocks"; 4 | import { runInLaravel, template } from "./../support/php"; 5 | 6 | const modelPaths = ["app", "app/Models"]; 7 | 8 | const load = () => { 9 | return runInLaravel( 10 | template("models", { 11 | model_paths: JSON.stringify(modelPaths), 12 | }), 13 | "Eloquent Attributes and Relations", 14 | ).then((result) => { 15 | if (!result) { 16 | return {}; 17 | } 18 | 19 | writeEloquentDocBlocks(result.models, result.builderMethods); 20 | 21 | return result.models; 22 | }); 23 | }; 24 | 25 | export const getModels = repository({ 26 | load, 27 | pattern: modelPaths 28 | .concat(["database/migrations"]) 29 | .map((path) => `${path}/*.php`), 30 | itemsDefault: {}, 31 | }); 32 | -------------------------------------------------------------------------------- /src/repositories/paths.ts: -------------------------------------------------------------------------------- 1 | import { runInLaravel } from "@src/support/php"; 2 | import { repository } from "."; 3 | 4 | interface PathItem { 5 | key: string; 6 | path: string; 7 | } 8 | 9 | export const getPaths = repository({ 10 | load: () => { 11 | return runInLaravel<{ key: string; path: string }[]>( 12 | ` 13 | echo json_encode([ 14 | [ 15 | 'key' => 'base_path', 16 | 'path' => base_path(), 17 | ], 18 | [ 19 | 'key' => 'resource_path', 20 | 'path' => resource_path(), 21 | ], 22 | [ 23 | 'key' => 'config_path', 24 | 'path' => config_path(), 25 | ], 26 | [ 27 | 'key' => 'app_path', 28 | 'path' => app_path(), 29 | ], 30 | [ 31 | 'key' => 'database_path', 32 | 'path' => database_path(), 33 | ], 34 | [ 35 | 'key' => 'lang_path', 36 | 'path' => lang_path(), 37 | ], 38 | [ 39 | 'key' => 'public_path', 40 | 'path' => public_path(), 41 | ], 42 | [ 43 | 'key' => 'storage_path', 44 | 'path' => storage_path(), 45 | ], 46 | ]); 47 | `, 48 | "Paths", 49 | ); 50 | }, 51 | pattern: "config/{,*,**/*}.php", 52 | itemsDefault: [], 53 | reloadOnComposerChanges: false, 54 | }); 55 | -------------------------------------------------------------------------------- /src/repositories/routes.ts: -------------------------------------------------------------------------------- 1 | import { inAppDirs } from "@src/support/fileWatcher"; 2 | import { repository } from "."; 3 | import { runInLaravel, template } from "../support/php"; 4 | 5 | interface RouteItem { 6 | method: string; 7 | uri: string; 8 | name: string; 9 | action: string; 10 | parameters: string[]; 11 | filename: string | null; 12 | line: number | null; 13 | } 14 | 15 | const routesPattern = "{[Rr]oute}{,s}{.php,/*.php,/**/*.php}"; 16 | 17 | export const getRoutes = repository({ 18 | load: () => { 19 | return runInLaravel(template("routes"), "HTTP Routes"); 20 | }, 21 | pattern: [inAppDirs(`{,**/}${routesPattern}`), routesPattern], 22 | itemsDefault: [], 23 | }); 24 | -------------------------------------------------------------------------------- /src/repositories/translations.ts: -------------------------------------------------------------------------------- 1 | import { runInLaravel, template } from "@src/support/php"; 2 | import { projectPath } from "@src/support/project"; 3 | import { waitForValue } from "@src/support/util"; 4 | import { repository } from "."; 5 | 6 | export interface TranslationItem { 7 | [key: string]: { 8 | value: string; 9 | path: string; 10 | line: number; 11 | params: string[]; 12 | }; 13 | } 14 | 15 | interface TranslationGroupResult { 16 | default: string; 17 | translations: { 18 | [key: string]: TranslationItem; 19 | }; 20 | languages: string[]; 21 | } 22 | 23 | interface TranslationGroupPhpResult { 24 | default: string; 25 | translations: { 26 | [key: string]: { 27 | [key: string]: [number, number, number, number | null]; 28 | }; 29 | }; 30 | params: string[][]; 31 | paths: string[]; 32 | values: string[]; 33 | to_watch: string[]; 34 | languages: string[]; 35 | } 36 | 37 | let dirsToWatch: string[] | null = null; 38 | 39 | const load = () => { 40 | return runInLaravel( 41 | template("translations"), 42 | "Translations", 43 | ).then((res) => { 44 | const result: TranslationGroupResult["translations"] = {}; 45 | 46 | Object.entries(res.translations).forEach( 47 | ([namespace, translations]) => { 48 | result[namespace] = {}; 49 | 50 | Object.entries(translations).forEach(([key, value]) => { 51 | const [v, p, li, pa] = value; 52 | 53 | result[namespace][key] = { 54 | value: res.values[v], 55 | path: projectPath(res.paths[p]), 56 | line: li, 57 | params: pa === null ? [] : res.params[pa], 58 | }; 59 | }); 60 | }, 61 | ); 62 | 63 | dirsToWatch = res.to_watch; 64 | 65 | return { 66 | default: res.default, 67 | translations: result, 68 | languages: res.languages, 69 | }; 70 | }); 71 | }; 72 | 73 | export const getTranslations = repository({ 74 | load, 75 | pattern: () => 76 | waitForValue(() => dirsToWatch).then((value) => { 77 | if (value === null || value.length === 0) { 78 | return null; 79 | } 80 | 81 | return `{${value.join(",")}}/{*,**/*}`; 82 | }), 83 | itemsDefault: { 84 | default: "", 85 | translations: {}, 86 | languages: [], 87 | }, 88 | }); 89 | -------------------------------------------------------------------------------- /src/repositories/views.ts: -------------------------------------------------------------------------------- 1 | import { inAppDirs } from "@src/support/fileWatcher"; 2 | import { runInLaravel, template } from "@src/support/php"; 3 | import { repository } from "."; 4 | 5 | export interface ViewItem { 6 | key: string; 7 | path: string; 8 | isVendor: boolean; 9 | } 10 | 11 | const load = () => { 12 | return runInLaravel< 13 | { 14 | key: string; 15 | path: string; 16 | isVendor: boolean; 17 | }[] 18 | >(template("views")).then((results) => { 19 | return results.map(({ key, path, isVendor }) => { 20 | return { 21 | key, 22 | path, 23 | isVendor, 24 | }; 25 | }); 26 | }); 27 | }; 28 | 29 | export const getViews = repository({ 30 | load, 31 | pattern: inAppDirs("{,**/}{view,views}/{*,**/*}"), 32 | itemsDefault: [], 33 | fileWatcherEvents: ["create", "delete"], 34 | }); 35 | -------------------------------------------------------------------------------- /src/support/config.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { GeneratedConfigKey } from "./generated-config"; 3 | 4 | type ConfigKey = 5 | | GeneratedConfigKey 6 | | "basePath" 7 | | "phpEnvironment" 8 | | "phpCommand" 9 | | "tests.docker.enabled" 10 | | "tests.ssh.enabled" 11 | | "tests.suiteSuffix" 12 | | "showErrorPopups" 13 | | "blade.autoSpaceTags" 14 | | "eloquent.generateDocBlocks" 15 | | "env.viteQuickFix"; 16 | 17 | export const config = (key: ConfigKey, fallback: T): T => 18 | vscode.workspace.getConfiguration("Laravel").get(key, fallback); 19 | 20 | export const configKey = (key: ConfigKey): string => `Laravel.${key}`; 21 | 22 | export const configAffected = ( 23 | event: vscode.ConfigurationChangeEvent, 24 | ...keys: ConfigKey[] 25 | ): boolean => keys.some((key) => event.affectsConfiguration(configKey(key))); 26 | 27 | export type PhpEnvironment = 28 | | "auto" 29 | | "herd" 30 | | "valet" 31 | | "sail" 32 | | "local" 33 | | "lando"; 34 | -------------------------------------------------------------------------------- /src/support/debug.ts: -------------------------------------------------------------------------------- 1 | import os from "os"; 2 | import { getCommandTemplate, runInLaravel } from "./php"; 3 | 4 | export const debugInfo: Record = {}; 5 | 6 | export const collectDebugInfo = () => { 7 | debugInfo["os"] = os.platform(); 8 | debugInfo["arch"] = os.arch(); 9 | 10 | runInLaravel(` 11 | echo json_encode([ 12 | 'php_version' => phpversion(), 13 | 'laravel_version' => app()->version(), 14 | ]); 15 | `).then((output) => { 16 | if (output) { 17 | for (const key in output as Record) { 18 | // @ts-ignore 19 | debugInfo[key] = output[key]; 20 | } 21 | } 22 | 23 | debugInfo["php_command"] = getCommandTemplate(); 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /src/support/doc.ts: -------------------------------------------------------------------------------- 1 | import { repository } from "@src/repositories"; 2 | import { AutocompleteParsingResult } from "@src/types"; 3 | import { 4 | Diagnostic, 5 | DocumentLink, 6 | Hover, 7 | Position, 8 | ProviderResult, 9 | Range, 10 | TextDocument, 11 | Uri, 12 | } from "vscode"; 13 | import { FeatureTag, ValidDetectParamTypes } from ".."; 14 | import { detectInDoc, isInHoverRange } from "./parser"; 15 | 16 | export const findWarningsInDoc = ( 17 | doc: TextDocument, 18 | regex: string, 19 | cb: (match: RegExpExecArray, range: Range) => Promise, 20 | matchIndex: number = 0, 21 | ): Promise => { 22 | return findMatchesInDocAsync( 23 | doc, 24 | regex, 25 | (match, range) => { 26 | return cb(match, range); 27 | }, 28 | matchIndex, 29 | ).then((items) => items.filter((item) => item !== null)); 30 | }; 31 | 32 | export const findLinksInDoc = ( 33 | doc: TextDocument, 34 | regex: string, 35 | cb: (match: RegExpExecArray) => Uri | null, 36 | matchIndex: number = 0, 37 | ): DocumentLink[] => { 38 | return findMatchesInDoc( 39 | doc, 40 | regex, 41 | (match, range) => { 42 | const item = cb(match); 43 | 44 | if (item === null) { 45 | return null; 46 | } 47 | 48 | return new DocumentLink(range, item); 49 | }, 50 | matchIndex, 51 | ); 52 | }; 53 | 54 | // TODO: This doesn't really belong here, it has to do with Hovering, but fine for now 55 | export const findHoverMatchesInDoc = ( 56 | doc: TextDocument, 57 | pos: Position, 58 | toFind: FeatureTag, 59 | repo: ReturnType, 60 | cb: ( 61 | match: string, 62 | arg: { 63 | param: AutocompleteParsingResult.StringValue; 64 | index: number; 65 | item: AutocompleteParsingResult.ContextValue; 66 | }, 67 | ) => ProviderResult, 68 | validParamTypes?: ValidDetectParamTypes[], 69 | ): ProviderResult => { 70 | const linkRange = doc.getWordRangeAtPosition( 71 | pos, 72 | new RegExp(/(['"])(.*?)\1/), 73 | ); 74 | 75 | if (!linkRange) { 76 | return null; 77 | } 78 | 79 | return detectInDoc, "string" | "array">( 80 | doc, 81 | toFind, 82 | repo, 83 | ({ param, index, item }) => { 84 | if (param.type === "string") { 85 | if (!isInHoverRange(linkRange, param)) { 86 | return null; 87 | } 88 | 89 | return cb(doc.getText(linkRange).replace(/^['"]|['"]$/g, ""), { 90 | param, 91 | index, 92 | item, 93 | }); 94 | } 95 | 96 | if (param.type === "array") { 97 | const value = param.children.find( 98 | ({ value }) => 99 | value?.type === "string" && 100 | isInHoverRange(linkRange, value), 101 | ) as AutocompleteParsingResult.StringValue | undefined; 102 | 103 | if (!value) { 104 | return null; 105 | } 106 | 107 | return cb(doc.getText(linkRange).replace(/^['"]|['"]$/g, ""), { 108 | param: value, 109 | index, 110 | item, 111 | }); 112 | } 113 | 114 | return null; 115 | }, 116 | validParamTypes, 117 | ).then( 118 | (results) => results.flat().find((result) => result !== null) || null, 119 | ); 120 | }; 121 | 122 | // TODO: This is a duplication of findMatchesInDocAsync, but I don't want to change it right now 123 | export const findMatchesInDocAsync = async ( 124 | doc: TextDocument, 125 | regex: string, 126 | cb: (match: RegExpExecArray, range: Range) => Promise, 127 | matchIndex: number = 0, 128 | ): Promise => { 129 | let items: T[] = []; 130 | let index = 0; 131 | 132 | while (index < doc.lineCount) { 133 | let finalRegex = new RegExp(regex, "gd"); 134 | let line = doc.lineAt(index); 135 | let match = finalRegex.exec(line.text); 136 | 137 | if (match !== null) { 138 | let start = new Position( 139 | line.lineNumber, 140 | match.indices?.[matchIndex][0] || match.index, 141 | ); 142 | let end = start.translate(0, match[matchIndex].length); 143 | 144 | let item = await cb(match, new Range(start, end)); 145 | 146 | if (item !== null) { 147 | items.push(item); 148 | } 149 | } 150 | 151 | index++; 152 | } 153 | 154 | return items; 155 | }; 156 | 157 | export const findMatchesInDoc = ( 158 | doc: TextDocument, 159 | regex: string, 160 | cb: (match: RegExpExecArray, range: Range) => T | null, 161 | matchIndex: number = 0, 162 | ): T[] => { 163 | let items: T[] = []; 164 | let index = 0; 165 | 166 | while (index < doc.lineCount) { 167 | let finalRegex = new RegExp(regex, "gd"); 168 | let line = doc.lineAt(index); 169 | let match = finalRegex.exec(line.text); 170 | 171 | if (match !== null) { 172 | let start = new Position( 173 | line.lineNumber, 174 | match.indices?.[matchIndex][0] || match.index, 175 | ); 176 | let end = start.translate(0, match[matchIndex].length); 177 | 178 | let item = cb(match, new Range(start, end)); 179 | 180 | if (item !== null) { 181 | items.push(item); 182 | } 183 | } 184 | 185 | index++; 186 | } 187 | 188 | return items; 189 | }; 190 | -------------------------------------------------------------------------------- /src/support/fileWatcher.ts: -------------------------------------------------------------------------------- 1 | import { readdirSync } from "fs"; 2 | import * as vscode from "vscode"; 3 | import { getWorkspaceFolders, hasWorkspace } from "./project"; 4 | import { debounce, leadingDebounce } from "./util"; 5 | 6 | export type FileEvent = "change" | "create" | "delete"; 7 | 8 | let watchers: vscode.FileSystemWatcher[] = []; 9 | 10 | export type WatcherPattern = 11 | | string 12 | | string[] 13 | | (() => Promise); 14 | 15 | export const defaultFileEvents: FileEvent[] = ["change", "create", "delete"]; 16 | 17 | export const loadAndWatch = ( 18 | load: () => void, 19 | patterns: WatcherPattern, 20 | events: FileEvent[] = defaultFileEvents, 21 | reloadOnComposerChanges: boolean = true, 22 | ): void => { 23 | if (!hasWorkspace()) { 24 | return; 25 | } 26 | 27 | load(); 28 | 29 | const loadFunc = leadingDebounce(load, 1000); 30 | 31 | if (patterns instanceof Function) { 32 | patterns().then((result) => { 33 | if (result !== null) { 34 | createFileWatcher( 35 | result, 36 | loadFunc, 37 | events, 38 | reloadOnComposerChanges, 39 | ); 40 | } 41 | }); 42 | } else { 43 | createFileWatcher(patterns, loadFunc, events, reloadOnComposerChanges); 44 | } 45 | }; 46 | 47 | let appDirsRead = false; 48 | const appDirs: string[] = []; 49 | const ignoreDirs = ["node_modules", ".git", "vendor", "storage"]; 50 | 51 | export const inAppDirs = (pattern: string) => { 52 | if (!appDirsRead) { 53 | appDirsRead = true; 54 | readdirSync(getWorkspaceFolders()[0].uri.fsPath, { 55 | withFileTypes: true, 56 | }).forEach((file) => { 57 | if (file.isDirectory() && !ignoreDirs.includes(file.name)) { 58 | appDirs.push(file.name); 59 | } 60 | }); 61 | } 62 | 63 | if (appDirs.length === 0) { 64 | return pattern; 65 | } 66 | 67 | return `{${appDirs.join(",")}}${pattern}`; 68 | }; 69 | 70 | const patternWatchers: Record< 71 | string, 72 | { 73 | watcher: vscode.FileSystemWatcher; 74 | callbacks: [ 75 | { 76 | callback: (e: vscode.Uri) => void; 77 | events: FileEvent[]; 78 | reloadOnComposerChanges: boolean; 79 | }, 80 | ]; 81 | } 82 | > = {}; 83 | 84 | export const watchForComposerChanges = () => { 85 | const onChange = debounce((e) => { 86 | for (const pattern in patternWatchers) { 87 | patternWatchers[pattern].callbacks.forEach((cb) => { 88 | if (cb.reloadOnComposerChanges) { 89 | cb.callback(e); 90 | } 91 | }); 92 | } 93 | }, 1000); 94 | 95 | const watcher = vscode.workspace.createFileSystemWatcher( 96 | new vscode.RelativePattern( 97 | getWorkspaceFolders()[0], 98 | "vendor/composer/autoload_*.php", 99 | ), 100 | ); 101 | 102 | watcher.onDidChange(onChange); 103 | watcher.onDidCreate(onChange); 104 | watcher.onDidDelete(onChange); 105 | 106 | registerWatcher(watcher); 107 | }; 108 | 109 | export const createFileWatcher = ( 110 | patterns: string | string[], 111 | callback: (e: vscode.Uri) => void, 112 | events: FileEvent[] = defaultFileEvents, 113 | reloadOnComposerChanges: boolean = true, 114 | ): vscode.FileSystemWatcher[] => { 115 | if (!hasWorkspace()) { 116 | return []; 117 | } 118 | 119 | patterns = typeof patterns === "string" ? [patterns] : patterns; 120 | 121 | return patterns.map((pattern) => { 122 | if (patternWatchers[pattern]) { 123 | patternWatchers[pattern].callbacks.push({ 124 | callback, 125 | events, 126 | reloadOnComposerChanges, 127 | }); 128 | 129 | return patternWatchers[pattern].watcher; 130 | } 131 | 132 | const watcher = vscode.workspace.createFileSystemWatcher( 133 | new vscode.RelativePattern(getWorkspaceFolders()[0], pattern), 134 | ); 135 | 136 | watcher.onDidChange((...args) => { 137 | patternWatchers[pattern].callbacks.forEach((cb) => { 138 | if (cb.events.includes("change")) { 139 | cb.callback(...args); 140 | } 141 | }); 142 | }); 143 | 144 | watcher.onDidCreate((...args) => { 145 | patternWatchers[pattern].callbacks.forEach((cb) => { 146 | if (cb.events.includes("create")) { 147 | cb.callback(...args); 148 | } 149 | }); 150 | }); 151 | 152 | watcher.onDidDelete((...args) => { 153 | patternWatchers[pattern].callbacks.forEach((cb) => { 154 | if (cb.events.includes("delete")) { 155 | cb.callback(...args); 156 | } 157 | }); 158 | }); 159 | 160 | patternWatchers[pattern] = { 161 | watcher, 162 | callbacks: [{ callback, events, reloadOnComposerChanges }], 163 | }; 164 | 165 | registerWatcher(watcher); 166 | 167 | return watcher; 168 | }); 169 | }; 170 | 171 | export const registerWatcher = (watcher: vscode.FileSystemWatcher) => { 172 | watchers.push(watcher); 173 | }; 174 | 175 | export const disposeWatchers = () => { 176 | watchers.forEach((watcher) => watcher.dispose()); 177 | watchers = []; 178 | }; 179 | -------------------------------------------------------------------------------- /src/support/generated-config.ts: -------------------------------------------------------------------------------- 1 | export type GeneratedConfigKey = 'appBinding.diagnostics' | 'appBinding.hover' | 'appBinding.link' | 'appBinding.completion' | 'asset.diagnostics' | 'asset.hover' | 'asset.link' | 'asset.completion' | 'auth.diagnostics' | 'auth.hover' | 'auth.link' | 'auth.completion' | 'bladeComponent.link' | 'bladeComponent.completion' | 'bladeComponent.hover' | 'config.diagnostics' | 'config.hover' | 'config.link' | 'config.completion' | 'controllerAction.diagnostics' | 'controllerAction.hover' | 'controllerAction.link' | 'controllerAction.completion' | 'env.diagnostics' | 'env.hover' | 'env.link' | 'env.completion' | 'inertia.diagnostics' | 'inertia.hover' | 'inertia.link' | 'inertia.completion' | 'livewireComponent.link' | 'livewireComponent.completion' | 'middleware.diagnostics' | 'middleware.hover' | 'middleware.link' | 'middleware.completion' | 'mix.diagnostics' | 'mix.hover' | 'mix.link' | 'mix.completion' | 'paths.link' | 'route.diagnostics' | 'route.hover' | 'route.link' | 'route.completion' | 'storage.link' | 'storage.completion' | 'storage.diagnostics' | 'translation.diagnostics' | 'translation.hover' | 'translation.link' | 'translation.completion' | 'view.diagnostics' | 'view.hover' | 'view.link' | 'view.completion'; 2 | -------------------------------------------------------------------------------- /src/support/logger.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import { window } from "vscode"; 4 | 5 | let channel = window.createOutputChannel("Laravel", { log: true }); 6 | 7 | const info = (message: string, ...args: any[]) => { 8 | channel.info(message, ...args); 9 | }; 10 | 11 | const warn = (message: string, ...args: any[]) => { 12 | channel.warn(message, ...args); 13 | }; 14 | 15 | const error = (message: string, ...args: any[]) => { 16 | channel.error(message, ...args); 17 | }; 18 | 19 | const bigInfo = (message: string, ...args: any[]) => { 20 | channel.info("---------------"); 21 | channel.info(message, ...args); 22 | channel.info("---------------"); 23 | }; 24 | 25 | export { bigInfo, channel, error, info, warn }; 26 | -------------------------------------------------------------------------------- /src/support/patterns.ts: -------------------------------------------------------------------------------- 1 | export const wordMatchRegex = /[\w\d\-_\.\:\\\/@]+/g; 2 | 3 | const funcRegex = (funcs: string[]) => { 4 | funcs = funcs.map((item) => `${item}\\(['"]`); 5 | return `(?<=${funcs.join("|")})(?:[^'"\\s]+(?:\\/[^'"\\s]+)*)`; 6 | }; 7 | 8 | export const viewMatchRegex = (() => { 9 | return funcRegex([ 10 | "view", 11 | "markdown", 12 | "assertViewIs", 13 | "@include", 14 | "@extends", 15 | "@component", 16 | // TODO: Deal with aliases 17 | "View::make", 18 | ]); 19 | })(); 20 | 21 | export const inertiaMatchRegex = (() => { 22 | return funcRegex(["inertia", "Inertia::(?:render|modal)"]); 23 | })(); 24 | 25 | export const inertiaRouteMatchRegex = (() => { 26 | return `Route::(?:inertia\\(['"](?:.+)['"],\\s?['"](.+)['"])`; 27 | })(); 28 | 29 | export const configMatchRegex = (() => { 30 | return funcRegex(["config", "Config::get"]); 31 | })(); 32 | 33 | export const authMatchRegex = (() => { 34 | return funcRegex([ 35 | "Gate::(?:has|allows|denies|check|any|none|authorize|inspect)", 36 | "can", 37 | "@can", 38 | "@cannot", 39 | "@canany", 40 | ]); 41 | })(); 42 | 43 | export const assetMatchRegex = (() => { 44 | return funcRegex(["asset"]); 45 | })(); 46 | 47 | export const mixManifestMatchRegex = (() => { 48 | return funcRegex(["mix"]); 49 | })(); 50 | 51 | export const appBindingMatchRegex = (() => { 52 | return funcRegex(["app", "App::make"]); 53 | })(); 54 | 55 | export const translationBindingMatchRegex = (() => { 56 | return funcRegex(["trans", "__"]); 57 | })(); 58 | 59 | export const middlewareMatchRegex = (() => { 60 | return funcRegex(["middleware", "withoutMiddleware"]); 61 | })(); 62 | 63 | export const routeMatchRegex = (() => { 64 | return funcRegex(["route", "signedRoute"]); 65 | })(); 66 | 67 | // Route::get('/profile', 'ProfileController@edit') 68 | export const controllerActionRegex = `Route::(.+)\\(['"](.+)['"],\\s?['"]((?:.+)@(?:[^)]+))['"]`; 69 | 70 | export const envMatchRegex = (() => { 71 | return funcRegex(["env"]); 72 | })(); 73 | -------------------------------------------------------------------------------- /src/support/popup.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { config } from "./config"; 3 | import { debugInfo } from "./debug"; 4 | import { channel, error } from "./logger"; 5 | 6 | let showErrorPopups = config("showErrorPopups", false); 7 | let lastErrorMessageShownAt = 0; 8 | const maxMessageInterval = 10; 9 | 10 | export const showErrorPopup = (...errors: string[]) => { 11 | errors.forEach((message) => error(message)); 12 | 13 | if ( 14 | !showErrorPopups || 15 | lastErrorMessageShownAt + maxMessageInterval > Date.now() / 1000 16 | ) { 17 | return; 18 | } 19 | 20 | lastErrorMessageShownAt = Date.now() / 1000; 21 | 22 | const actions = [ 23 | { 24 | title: "View Error", 25 | command: () => { 26 | channel.show(true); 27 | }, 28 | }, 29 | { 30 | title: "Copy Error to Clipboard", 31 | command: () => { 32 | const finalMessage = [ 33 | "Debug Info", 34 | "", 35 | JSON.stringify(debugInfo, null, 2), 36 | "", 37 | "-".repeat(40), 38 | "", 39 | ...errors, 40 | ]; 41 | 42 | vscode.env.clipboard.writeText(finalMessage.join("\n")); 43 | }, 44 | }, 45 | { 46 | title: "Don't show again", 47 | command: () => { 48 | showErrorPopups = false; 49 | }, 50 | }, 51 | ]; 52 | 53 | vscode.window 54 | .showErrorMessage( 55 | "Error in Laravel Extension", 56 | ...actions.map((action) => action.title), 57 | ) 58 | .then((val: string | undefined) => { 59 | actions.find((action) => action.title === val)?.command(); 60 | }); 61 | }; 62 | -------------------------------------------------------------------------------- /src/support/project.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | import * as vscode from "vscode"; 4 | import { config } from "./config"; 5 | 6 | let internalVendorExists: boolean | null = null; 7 | 8 | export const setInternalVendorExists = (value: boolean) => { 9 | internalVendorExists = value; 10 | }; 11 | 12 | export const internalVendorPath = (subPath = ""): string => { 13 | const baseDir = path.join("vendor", "_laravel_ide"); 14 | 15 | if (internalVendorExists !== true) { 16 | const baseVendorDir = projectPath(baseDir); 17 | 18 | internalVendorExists = fs.existsSync(baseVendorDir); 19 | 20 | if (!internalVendorExists) { 21 | fs.mkdirSync(baseVendorDir, { recursive: true }); 22 | } 23 | } 24 | 25 | return projectPath(`${baseDir}/${subPath}`); 26 | }; 27 | 28 | const trimFirstSlash = (srcPath: string): string => { 29 | return srcPath[0] === path.sep ? srcPath.substring(1) : srcPath; 30 | }; 31 | 32 | export const basePath = (srcPath = ""): string => { 33 | return path.join(config("basePath", ""), srcPath); 34 | }; 35 | 36 | export const projectPath = (srcPath = ""): string => { 37 | srcPath = basePath(srcPath); 38 | 39 | for (let workspaceFolder of getWorkspaceFolders()) { 40 | if ( 41 | fs.existsSync( 42 | path.join(workspaceFolder.uri.fsPath, basePath("artisan")), 43 | ) 44 | ) { 45 | return path.join(workspaceFolder.uri.fsPath, srcPath); 46 | } 47 | } 48 | 49 | return ""; 50 | }; 51 | 52 | export const relativePath = (srcPath: string): string => { 53 | for (let workspaceFolder of getWorkspaceFolders()) { 54 | if (srcPath.startsWith(workspaceFolder.uri.fsPath)) { 55 | return trimFirstSlash( 56 | srcPath.replace( 57 | path.join(workspaceFolder.uri.fsPath, basePath()), 58 | "", 59 | ), 60 | ); 61 | } 62 | } 63 | 64 | return srcPath; 65 | }; 66 | 67 | const resolvePath = (basePath: string, relativePath: string): string => { 68 | if (basePath.startsWith(".") && hasWorkspace()) { 69 | basePath = path.resolve(getWorkspaceFolders()[0].uri.fsPath, basePath); 70 | } 71 | 72 | return path.join(basePath, relativePath); 73 | }; 74 | 75 | export const hasWorkspace = (): boolean => { 76 | return ( 77 | vscode.workspace.workspaceFolders instanceof Array && 78 | vscode.workspace.workspaceFolders.length > 0 79 | ); 80 | }; 81 | 82 | export const getWorkspaceFolders = () => 83 | vscode.workspace.workspaceFolders || []; 84 | 85 | export const projectPathExists = (path: string): boolean => { 86 | return fs.existsSync(projectPath(path)); 87 | }; 88 | 89 | export const readFileInProject = (path: string): string => { 90 | return fs.readFileSync(projectPath(path), "utf8"); 91 | }; 92 | -------------------------------------------------------------------------------- /src/support/util.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from "crypto"; 2 | import fs from "fs"; 3 | import os from "os"; 4 | import path from "path"; 5 | import * as vscode from "vscode"; 6 | import { relativePath } from "./project"; 7 | 8 | /** 9 | * Get indent space based on user configuration 10 | */ 11 | export const indent = (text: string = "", repeat: number = 1): string => { 12 | const editor = vscode.window.activeTextEditor; 13 | 14 | if (editor && editor.options.insertSpaces) { 15 | return " ".repeat(editor.options.tabSize * repeat) + text; 16 | } 17 | 18 | return "\t" + text; 19 | }; 20 | 21 | export const trimQuotes = (text: string): string => 22 | text.substring(1, text.length - 1); 23 | 24 | export const relativeMarkdownLink = (uri: vscode.Uri): string => { 25 | return `[${relativePath(uri.path)}](${uri})`; 26 | }; 27 | 28 | export const toArray = (value: T | T[]): T[] => { 29 | return Array.isArray(value) ? value : [value]; 30 | }; 31 | 32 | export const contract = (className: string): string => { 33 | return `Illuminate\\Contracts\\${className}`; 34 | }; 35 | 36 | export const facade = (className: string): string[] => { 37 | return [className, support(`Facades\\${className}`)]; 38 | }; 39 | 40 | export const support = (className: string): string => { 41 | return `Illuminate\\Support\\${className}`; 42 | }; 43 | 44 | export const md5 = (string: string) => { 45 | const hash = crypto.createHash("md5"); 46 | hash.update(string); 47 | return hash.digest("hex"); 48 | }; 49 | 50 | let tempDir: string; 51 | 52 | export const tempPath = (...paths: string[]): string => { 53 | if (!tempDir) { 54 | tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "vscode-laravel")); 55 | } 56 | 57 | return path.join(tempDir, ...paths); 58 | }; 59 | 60 | export const cleanUpTemp = () => { 61 | if (tempDir) { 62 | fs.rmdirSync(tempDir, { recursive: true }); 63 | } 64 | }; 65 | 66 | export const debounce = any>( 67 | func: T, 68 | wait: number, 69 | ): T => { 70 | let timeout: NodeJS.Timeout; 71 | 72 | return function (this: any, ...args: any[]) { 73 | clearTimeout(timeout); 74 | 75 | timeout = setTimeout(() => { 76 | func.apply(this, args); 77 | }, wait); 78 | } as T; 79 | }; 80 | 81 | export const leadingDebounce = any>( 82 | func: T, 83 | wait: number, 84 | ): T => { 85 | let timeout: NodeJS.Timeout; 86 | let lastInvocation = 0; 87 | 88 | return function (this: any, ...args: any[]) { 89 | clearTimeout(timeout); 90 | 91 | if (lastInvocation < Date.now() - wait) { 92 | // It's been a while since the last invocation, just call the function, no need to wait 93 | func.apply(this, args); 94 | } else { 95 | timeout = setTimeout(() => { 96 | func.apply(this, args); 97 | }, wait); 98 | } 99 | 100 | lastInvocation = Date.now(); 101 | } as T; 102 | }; 103 | 104 | export const waitForValue = ( 105 | value: () => T, 106 | interval = 100, 107 | maxAttempts = 20, 108 | ): Promise => 109 | new Promise((resolve) => { 110 | let attempts = 0; 111 | 112 | const checkForValue = () => { 113 | attempts++; 114 | 115 | if (attempts > maxAttempts) { 116 | return resolve(value()); 117 | } 118 | 119 | if (value() === null) { 120 | return setTimeout(checkForValue, 100); 121 | } 122 | 123 | resolve(value()); 124 | }; 125 | 126 | checkForValue(); 127 | }); 128 | 129 | export const createIndexMapping = ( 130 | items: [string | string[], Record][], 131 | ) => { 132 | const mapping: Record> = {}; 133 | 134 | items.forEach(([keys, value]) => { 135 | keys = toArray(keys); 136 | 137 | keys.forEach((key) => { 138 | mapping[key] = value; 139 | }); 140 | }); 141 | 142 | return { 143 | mapping, 144 | get(className: string | null, methodName: string | null) { 145 | return mapping[className ?? ""][methodName ?? ""] ?? null; 146 | }, 147 | }; 148 | }; 149 | -------------------------------------------------------------------------------- /src/syntax/DocumentHighlight.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import * as html from "vscode-html-languageservice"; 3 | import * as lsTextDocument from "vscode-languageserver-textdocument"; 4 | 5 | const service = html.getLanguageService(); 6 | 7 | class DocumentHighlight implements vscode.DocumentHighlightProvider { 8 | provideDocumentHighlights( 9 | document: vscode.TextDocument, 10 | position: vscode.Position, 11 | token: vscode.CancellationToken, 12 | ): vscode.DocumentHighlight[] | Thenable { 13 | const doc = lsTextDocument.TextDocument.create( 14 | document.uri.fsPath, 15 | "html", 16 | 1, 17 | document.getText(), 18 | ); 19 | 20 | return service.findDocumentHighlights( 21 | doc, 22 | position, 23 | service.parseHTMLDocument(doc), 24 | ) as any[]; 25 | } 26 | } 27 | 28 | export default DocumentHighlight; 29 | -------------------------------------------------------------------------------- /src/templates/app.ts: -------------------------------------------------------------------------------- 1 | // This file was generated from php-templates/app.php, do not edit directly 2 | export default ` 3 | echo collect(app()->getBindings()) 4 | ->filter(fn ($binding) => ($binding['concrete'] ?? null) !== null) 5 | ->flatMap(function ($binding, $key) { 6 | $boundTo = new ReflectionFunction($binding['concrete']); 7 | 8 | $closureClass = $boundTo->getClosureScopeClass(); 9 | 10 | if ($closureClass === null) { 11 | return []; 12 | } 13 | 14 | return [ 15 | $key => [ 16 | 'path' => LaravelVsCode::relativePath($closureClass->getFileName()), 17 | 'class' => $closureClass->getName(), 18 | 'line' => $boundTo->getStartLine(), 19 | ], 20 | ]; 21 | })->toJson(); 22 | `; -------------------------------------------------------------------------------- /src/templates/auth.ts: -------------------------------------------------------------------------------- 1 | // This file was generated from php-templates/auth.php, do not edit directly 2 | export default ` 3 | collect(glob(base_path('**/Models/*.php')))->each(fn($file) => include_once($file)); 4 | 5 | $modelPolicies = collect(get_declared_classes()) 6 | ->filter(fn($class) => is_subclass_of($class, \\Illuminate\\Database\\Eloquent\\Model::class)) 7 | ->filter(fn($class) => !in_array($class, [ 8 | \\Illuminate\\Database\\Eloquent\\Relations\\Pivot::class, 9 | \\Illuminate\\Foundation\\Auth\\User::class, 10 | ])) 11 | ->flatMap(fn($class) => [ 12 | $class => \\Illuminate\\Support\\Facades\\Gate::getPolicyFor($class), 13 | ]) 14 | ->filter(fn($policy) => $policy !== null); 15 | 16 | function vsCodeGetAuthenticatable() { 17 | try { 18 | $guard = auth()->guard(); 19 | 20 | $reflection = new \\ReflectionClass($guard); 21 | 22 | if (!$reflection->hasProperty("provider")) { 23 | return null; 24 | } 25 | 26 | $property = $reflection->getProperty("provider"); 27 | $provider = $property->getValue($guard); 28 | 29 | if ($provider instanceof \\Illuminate\\Auth\\EloquentUserProvider) { 30 | $providerReflection = new \\ReflectionClass($provider); 31 | $modelProperty = $providerReflection->getProperty("model"); 32 | 33 | return str($modelProperty->getValue($provider))->prepend("\\\\")->toString(); 34 | } 35 | 36 | if ($provider instanceof \\Illuminate\\Auth\\DatabaseUserProvider) { 37 | return str(\\Illuminate\\Auth\\GenericUser::class)->prepend("\\\\")->toString(); 38 | } 39 | } catch (\\Exception | \\Throwable $e) { 40 | return null; 41 | } 42 | 43 | return null; 44 | } 45 | 46 | function vsCodeGetPolicyInfo($policy, $model) 47 | { 48 | $methods = (new ReflectionClass($policy))->getMethods(); 49 | 50 | return collect($methods)->map(fn(ReflectionMethod $method) => [ 51 | 'key' => $method->getName(), 52 | 'uri' => $method->getFileName(), 53 | 'policy' => is_string($policy) ? $policy : get_class($policy), 54 | 'model' => $model, 55 | 'line' => $method->getStartLine(), 56 | ])->filter(fn($ability) => !in_array($ability['key'], ['allow', 'deny'])); 57 | } 58 | 59 | echo json_encode([ 60 | 'authenticatable' => vsCodeGetAuthenticatable(), 61 | 'policies' => collect(\\Illuminate\\Support\\Facades\\Gate::abilities()) 62 | ->map(function ($policy, $key) { 63 | $reflection = new \\ReflectionFunction($policy); 64 | $policyClass = null; 65 | $closureThis = $reflection->getClosureThis(); 66 | 67 | if ($closureThis !== null) { 68 | if (get_class($closureThis) === \\Illuminate\\Auth\\Access\\Gate::class) { 69 | $vars = $reflection->getClosureUsedVariables(); 70 | 71 | if (isset($vars['callback'])) { 72 | [$policyClass, $method] = explode('@', $vars['callback']); 73 | 74 | $reflection = new \\ReflectionMethod($policyClass, $method); 75 | } 76 | } 77 | } 78 | 79 | return [ 80 | 'key' => $key, 81 | 'uri' => $reflection->getFileName(), 82 | 'policy' => $policyClass, 83 | 'line' => $reflection->getStartLine(), 84 | ]; 85 | }) 86 | ->merge( 87 | collect(\\Illuminate\\Support\\Facades\\Gate::policies())->flatMap(fn($policy, $model) => vsCodeGetPolicyInfo($policy, $model)), 88 | ) 89 | ->merge( 90 | $modelPolicies->flatMap(fn($policy, $model) => vsCodeGetPolicyInfo($policy, $model)), 91 | ) 92 | ->values() 93 | ->groupBy('key') 94 | ->map(fn($item) => $item->map(fn($i) => \\Illuminate\\Support\\Arr::except($i, 'key'))), 95 | ]); 96 | `; -------------------------------------------------------------------------------- /src/templates/blade-directives.ts: -------------------------------------------------------------------------------- 1 | // This file was generated from php-templates/blade-directives.php, do not edit directly 2 | export default ` 3 | echo collect(app(\\Illuminate\\View\\Compilers\\BladeCompiler::class)->getCustomDirectives()) 4 | ->map(function ($customDirective, $name) { 5 | if ($customDirective instanceof \\Closure) { 6 | return [ 7 | 'name' => $name, 8 | 'hasParams' => (new ReflectionFunction($customDirective))->getNumberOfParameters() >= 1, 9 | ]; 10 | } 11 | 12 | if (is_array($customDirective)) { 13 | return [ 14 | 'name' => $name, 15 | 'hasParams' => (new ReflectionMethod($customDirective[0], $customDirective[1]))->getNumberOfParameters() >= 1, 16 | ]; 17 | } 18 | 19 | return null; 20 | }) 21 | ->filter() 22 | ->values() 23 | ->toJson(); 24 | `; -------------------------------------------------------------------------------- /src/templates/bootstrap-laravel.ts: -------------------------------------------------------------------------------- 1 | // This file was generated from php-templates/bootstrap-laravel.php, do not edit directly 2 | export default ` 3 | error_reporting(E_ERROR | E_PARSE); 4 | 5 | define('LARAVEL_START', microtime(true)); 6 | 7 | require_once __DIR__ . '/../autoload.php'; 8 | 9 | class LaravelVsCode 10 | { 11 | public static function relativePath($path) 12 | { 13 | if (!str_contains($path, base_path())) { 14 | return (string) $path; 15 | } 16 | 17 | return ltrim(str_replace(base_path(), '', realpath($path) ?: $path), DIRECTORY_SEPARATOR); 18 | } 19 | 20 | public static function isVendor($path) 21 | { 22 | return str_contains($path, base_path("vendor")); 23 | } 24 | 25 | public static function outputMarker($key) 26 | { 27 | return '__VSCODE_LARAVEL_' . $key . '__'; 28 | } 29 | 30 | public static function startupError(\\Throwable $e) 31 | { 32 | throw new Error(self::outputMarker('STARTUP_ERROR') . ': ' . $e->getMessage()); 33 | } 34 | } 35 | 36 | try { 37 | $app = require_once __DIR__ . '/../../bootstrap/app.php'; 38 | } catch (\\Throwable $e) { 39 | LaravelVsCode::startupError($e); 40 | exit(1); 41 | } 42 | 43 | $app->register(new class($app) extends \\Illuminate\\Support\\ServiceProvider 44 | { 45 | public function boot() 46 | { 47 | config([ 48 | 'logging.channels.null' => [ 49 | 'driver' => 'monolog', 50 | 'handler' => \\Monolog\\Handler\\NullHandler::class, 51 | ], 52 | 'logging.default' => 'null', 53 | ]); 54 | } 55 | }); 56 | 57 | try { 58 | $kernel = $app->make(Illuminate\\Contracts\\Console\\Kernel::class); 59 | $kernel->bootstrap(); 60 | } catch (\\Throwable $e) { 61 | LaravelVsCode::startupError($e); 62 | exit(1); 63 | } 64 | 65 | echo LaravelVsCode::outputMarker('START_OUTPUT'); 66 | __VSCODE_LARAVEL_OUTPUT__; 67 | echo LaravelVsCode::outputMarker('END_OUTPUT'); 68 | 69 | exit(0); 70 | `; -------------------------------------------------------------------------------- /src/templates/configs.ts: -------------------------------------------------------------------------------- 1 | // This file was generated from php-templates/configs.php, do not edit directly 2 | export default ` 3 | $local = collect(glob(config_path("/*.php"))) 4 | ->merge(glob(config_path("**/*.php"))) 5 | ->map(fn ($path) => [ 6 | (string) str($path) 7 | ->replace([config_path('/'), ".php"], "") 8 | ->replace(DIRECTORY_SEPARATOR, "."), 9 | $path 10 | ]); 11 | 12 | $vendor = collect(glob(base_path("vendor/**/**/config/*.php")))->map(fn ( 13 | $path 14 | ) => [ 15 | (string) str($path) 16 | ->afterLast(DIRECTORY_SEPARATOR . "config" . DIRECTORY_SEPARATOR) 17 | ->replace(".php", "") 18 | ->replace(DIRECTORY_SEPARATOR, "."), 19 | $path 20 | ]); 21 | 22 | $configPaths = $local 23 | ->merge($vendor) 24 | ->groupBy(0) 25 | ->map(fn ($items)=>$items->pluck(1)); 26 | 27 | $cachedContents = []; 28 | $cachedParsed = []; 29 | 30 | function vsCodeGetConfigValue($value, $key, $configPaths) { 31 | $parts = explode(".", $key); 32 | $toFind = $key; 33 | $found = null; 34 | 35 | while (count($parts) > 0) { 36 | $toFind = implode(".", $parts); 37 | 38 | if ($configPaths->has($toFind)) { 39 | $found = $toFind; 40 | break; 41 | } 42 | 43 | array_pop($parts); 44 | } 45 | 46 | if ($found === null) { 47 | return null; 48 | } 49 | 50 | $file = null; 51 | $line = null; 52 | 53 | if ($found === $key) { 54 | $file = $configPaths->get($found)[0]; 55 | } else { 56 | foreach ($configPaths->get($found) as $path) { 57 | $cachedContents[$path] ??= file_get_contents($path); 58 | $cachedParsed[$path] ??= token_get_all($cachedContents[$path]); 59 | 60 | $keysToFind = str($key) 61 | ->replaceFirst($found, "") 62 | ->ltrim(".") 63 | ->explode("."); 64 | 65 | if (is_numeric($keysToFind->last())) { 66 | $index = $keysToFind->pop(); 67 | 68 | if ($index !== "0") { 69 | return null; 70 | } 71 | 72 | $key = collect(explode(".", $key)); 73 | $key->pop(); 74 | $key = $key->implode("."); 75 | $value = "array(...)"; 76 | } 77 | 78 | $nextKey = $keysToFind->shift(); 79 | $expectedDepth = 1; 80 | 81 | $depth = 0; 82 | 83 | foreach ($cachedParsed[$path] as $token) { 84 | if ($token === "[") { 85 | $depth++; 86 | } 87 | 88 | if ($token === "]") { 89 | $depth--; 90 | } 91 | 92 | if (!is_array($token)) { 93 | continue; 94 | } 95 | 96 | $str = trim($token[1], '"\\''); 97 | 98 | if ( 99 | $str === $nextKey && 100 | $depth === $expectedDepth && 101 | $token[0] === T_CONSTANT_ENCAPSED_STRING 102 | ) { 103 | $nextKey = $keysToFind->shift(); 104 | $expectedDepth++; 105 | 106 | if ($nextKey === null) { 107 | $file = $path; 108 | $line = $token[2]; 109 | break; 110 | } 111 | } 112 | } 113 | 114 | if ($file) { 115 | break; 116 | } 117 | } 118 | } 119 | 120 | return [ 121 | "name" => $key, 122 | "value" => $value, 123 | "file" => $file === null ? null : str_replace(base_path(DIRECTORY_SEPARATOR), '', $file), 124 | "line" => $line 125 | ]; 126 | } 127 | 128 | function vsCodeUnpackDottedKey($value, $key) { 129 | $arr = [$key => $value]; 130 | $parts = explode('.', $key); 131 | array_pop($parts); 132 | 133 | while (count($parts)) { 134 | $arr[implode('.', $parts)] = 'array(...)'; 135 | array_pop($parts); 136 | } 137 | 138 | return $arr; 139 | } 140 | 141 | echo collect(\\Illuminate\\Support\\Arr::dot(config()->all())) 142 | ->mapWithKeys(fn($value, $key) => vsCodeUnpackDottedKey($value, $key)) 143 | ->map(fn ($value, $key) => vsCodeGetConfigValue($value, $key, $configPaths)) 144 | ->filter() 145 | ->values() 146 | ->toJson(); 147 | `; -------------------------------------------------------------------------------- /src/templates/index.ts: -------------------------------------------------------------------------------- 1 | import app from "./app"; 2 | import auth from "./auth"; 3 | import bladeComponents from "./blade-components"; 4 | import bladeDirectives from "./blade-directives"; 5 | import bootstrapLaravel from "./bootstrap-laravel"; 6 | import configs from "./configs"; 7 | import inertia from "./inertia"; 8 | import middleware from "./middleware"; 9 | import models from "./models"; 10 | import routes from "./routes"; 11 | import translations from "./translations"; 12 | import views from "./views"; 13 | 14 | const templates = { 15 | app, 16 | auth, 17 | bladeComponents, 18 | bladeDirectives, 19 | bootstrapLaravel, 20 | configs, 21 | inertia, 22 | middleware, 23 | models, 24 | routes, 25 | translations, 26 | views, 27 | }; 28 | 29 | export type TemplateName = keyof typeof templates; 30 | 31 | export const getTemplate = (name: TemplateName) => { 32 | if (!templates[name]) { 33 | throw new Error("Template not found: " + name); 34 | } 35 | 36 | return templates[name]; 37 | }; 38 | -------------------------------------------------------------------------------- /src/templates/inertia.ts: -------------------------------------------------------------------------------- 1 | // This file was generated from php-templates/inertia.php, do not edit directly 2 | export default ` 3 | echo json_encode([ 4 | ...config('inertia.testing', []), 5 | 'page_paths' => collect(config('inertia.testing.page_paths', []))->flatMap(function($path) { 6 | $relativePath = LaravelVsCode::relativePath($path); 7 | 8 | return [$relativePath, mb_strtolower($relativePath)]; 9 | })->unique()->values(), 10 | ]); 11 | `; -------------------------------------------------------------------------------- /src/templates/middleware.ts: -------------------------------------------------------------------------------- 1 | // This file was generated from php-templates/middleware.php, do not edit directly 2 | export default ` 3 | function vsCodeGetReflectionMethod(ReflectionClass $reflected): ReflectionMethod { 4 | return match (true) { 5 | $reflected->hasMethod('__invoke') => $reflected->getMethod('__invoke'), 6 | default => $reflected->getMethod('handle'), 7 | }; 8 | } 9 | 10 | echo collect(app("Illuminate\\Contracts\\Http\\Kernel")->getMiddlewareGroups()) 11 | ->merge(app("Illuminate\\Contracts\\Http\\Kernel")->getRouteMiddleware()) 12 | ->map(function ($middleware, $key) { 13 | $result = [ 14 | "class" => null, 15 | "path" => null, 16 | "line" => null, 17 | "parameters" => null, 18 | "groups" => [], 19 | ]; 20 | 21 | if (is_array($middleware)) { 22 | $result["groups"] = collect($middleware)->map(function ($m) { 23 | if (!class_exists($m)) { 24 | return [ 25 | "class" => $m, 26 | "path" => null, 27 | "line" => null 28 | ]; 29 | } 30 | 31 | $reflected = new ReflectionClass($m); 32 | $reflectedMethod = vsCodeGetReflectionMethod($reflected); 33 | 34 | return [ 35 | "class" => $m, 36 | "path" => LaravelVsCode::relativePath($reflected->getFileName()), 37 | "line" => 38 | $reflectedMethod->getFileName() === $reflected->getFileName() 39 | ? $reflectedMethod->getStartLine() 40 | : null 41 | ]; 42 | })->all(); 43 | 44 | return $result; 45 | } 46 | 47 | $reflected = new ReflectionClass($middleware); 48 | $reflectedMethod = vsCodeGetReflectionMethod($reflected); 49 | 50 | $result = array_merge($result, [ 51 | "class" => $middleware, 52 | "path" => LaravelVsCode::relativePath($reflected->getFileName()), 53 | "line" => $reflectedMethod->getStartLine(), 54 | ]); 55 | 56 | $parameters = collect($reflectedMethod->getParameters()) 57 | ->filter(function ($rc) { 58 | return $rc->getName() !== "request" && $rc->getName() !== "next"; 59 | }) 60 | ->map(function ($rc) { 61 | return $rc->getName() . ($rc->isVariadic() ? "..." : ""); 62 | }); 63 | 64 | if ($parameters->isEmpty()) { 65 | return $result; 66 | } 67 | 68 | return array_merge($result, [ 69 | "parameters" => $parameters->implode(",") 70 | ]); 71 | }) 72 | ->toJson(); 73 | `; -------------------------------------------------------------------------------- /src/templates/routes.ts: -------------------------------------------------------------------------------- 1 | // This file was generated from php-templates/routes.php, do not edit directly 2 | export default ` 3 | $routes = new class { 4 | public function all() 5 | { 6 | return collect(app('router')->getRoutes()->getRoutes()) 7 | ->map(fn(\\Illuminate\\Routing\\Route $route) => $this->getRoute($route)) 8 | ->merge($this->getFolioRoutes()); 9 | } 10 | 11 | protected function getFolioRoutes() 12 | { 13 | try { 14 | $output = new \\Symfony\\Component\\Console\\Output\\BufferedOutput(); 15 | 16 | \\Illuminate\\Support\\Facades\\Artisan::call("folio:list", ["--json" => true], $output); 17 | 18 | $mountPaths = collect(app(\\Laravel\\Folio\\FolioManager::class)->mountPaths()); 19 | 20 | return collect(json_decode($output->fetch(), true))->map(fn($route) => $this->getFolioRoute($route, $mountPaths)); 21 | } catch (\\Exception | \\Throwable $e) { 22 | return []; 23 | } 24 | } 25 | 26 | protected function getFolioRoute($route, $mountPaths) 27 | { 28 | if ($mountPaths->count() === 1) { 29 | $mountPath = $mountPaths[0]; 30 | } else { 31 | $mountPath = $mountPaths->first(fn($mp) => file_exists($mp->path . DIRECTORY_SEPARATOR . $route['view'])); 32 | } 33 | 34 | $path = $route['view']; 35 | 36 | if ($mountPath) { 37 | $path = $mountPath->path . DIRECTORY_SEPARATOR . $path; 38 | } 39 | 40 | return [ 41 | 'method' => $route['method'], 42 | 'uri' => $route['uri'], 43 | 'name' => $route['name'], 44 | 'action' => null, 45 | 'parameters' => [], 46 | 'filename' => $path, 47 | 'line' => 0, 48 | ]; 49 | } 50 | 51 | protected function getRoute(\\Illuminate\\Routing\\Route $route) 52 | { 53 | try { 54 | $reflection = $this->getRouteReflection($route); 55 | } catch (\\Throwable $e) { 56 | $reflection = null; 57 | } 58 | 59 | return [ 60 | 'method' => collect($route->methods()) 61 | ->filter(fn($method) => $method !== 'HEAD') 62 | ->implode('|'), 63 | 'uri' => $route->uri(), 64 | 'name' => $route->getName(), 65 | 'action' => $route->getActionName(), 66 | 'parameters' => $route->parameterNames(), 67 | 'filename' => $reflection ? $reflection->getFileName() : null, 68 | 'line' => $reflection ? $reflection->getStartLine() : null, 69 | ]; 70 | } 71 | 72 | protected function getRouteReflection(\\Illuminate\\Routing\\Route $route) 73 | { 74 | if ($route->getActionName() === 'Closure') { 75 | return new \\ReflectionFunction($route->getAction()['uses']); 76 | } 77 | 78 | if (!str_contains($route->getActionName(), '@')) { 79 | return new \\ReflectionClass($route->getActionName()); 80 | } 81 | 82 | try { 83 | return new \\ReflectionMethod($route->getControllerClass(), $route->getActionMethod()); 84 | } catch (\\Throwable $e) { 85 | $namespace = app(\\Illuminate\\Routing\\UrlGenerator::class)->getRootControllerNamespace() 86 | ?? (app()->getNamespace() . 'Http\\Controllers'); 87 | 88 | return new \\ReflectionMethod( 89 | $namespace . '\\\\' . ltrim($route->getControllerClass(), '\\\\'), 90 | $route->getActionMethod(), 91 | ); 92 | } 93 | } 94 | }; 95 | 96 | echo $routes->all()->toJson(); 97 | `; -------------------------------------------------------------------------------- /src/templates/views.ts: -------------------------------------------------------------------------------- 1 | // This file was generated from php-templates/views.php, do not edit directly 2 | export default ` 3 | $blade = new class { 4 | public function getAllViews() 5 | { 6 | $finder = app("view")->getFinder(); 7 | 8 | $paths = collect($finder->getPaths())->flatMap(fn($path) => $this->findViews($path)); 9 | 10 | $hints = collect($finder->getHints())->flatMap( 11 | fn($paths, $key) => collect($paths)->flatMap( 12 | fn($path) => collect($this->findViews($path))->map( 13 | fn($value) => array_merge($value, ["key" => "{$key}::{$value["key"]}"]) 14 | ) 15 | ) 16 | ); 17 | 18 | [$local, $vendor] = $paths 19 | ->merge($hints) 20 | ->values() 21 | ->partition(fn($v) => !$v["isVendor"]); 22 | 23 | return $local 24 | ->sortBy("key", SORT_NATURAL) 25 | ->merge($vendor->sortBy("key", SORT_NATURAL)); 26 | } 27 | 28 | public function getAllComponents() 29 | { 30 | $namespaced = \\Illuminate\\Support\\Facades\\Blade::getClassComponentNamespaces(); 31 | $autoloaded = require base_path("vendor/composer/autoload_psr4.php"); 32 | $components = []; 33 | 34 | foreach ($namespaced as $key => $ns) { 35 | $path = null; 36 | 37 | foreach ($autoloaded as $namespace => $paths) { 38 | if (str_starts_with($ns, $namespace)) { 39 | foreach ($paths as $p) { 40 | $test = str($ns)->replace($namespace, '')->replace('\\\\', '/')->prepend($p . DIRECTORY_SEPARATOR)->toString(); 41 | 42 | if (is_dir($test)) { 43 | $path = $test; 44 | break; 45 | } 46 | } 47 | 48 | break; 49 | } 50 | } 51 | 52 | if (!$path) { 53 | continue; 54 | } 55 | 56 | $files = \\Symfony\\Component\\Finder\\Finder::create() 57 | ->files() 58 | ->name("*.php") 59 | ->in($path); 60 | 61 | foreach ($files as $file) { 62 | $realPath = $file->getRealPath(); 63 | 64 | $components[] = [ 65 | "path" => str_replace(base_path(DIRECTORY_SEPARATOR), '', $realPath), 66 | "isVendor" => str_contains($realPath, base_path("vendor")), 67 | "key" => str($realPath) 68 | ->replace(realpath($path), "") 69 | ->replace(".php", "") 70 | ->ltrim(DIRECTORY_SEPARATOR) 71 | ->replace(DIRECTORY_SEPARATOR, ".") 72 | ->kebab() 73 | ->prepend($key . "::"), 74 | ]; 75 | } 76 | } 77 | 78 | return $components; 79 | } 80 | 81 | protected function findViews($path) 82 | { 83 | $paths = []; 84 | 85 | if (!is_dir($path)) { 86 | return $paths; 87 | } 88 | 89 | $files = \\Symfony\\Component\\Finder\\Finder::create() 90 | ->files() 91 | ->name("*.blade.php") 92 | ->in($path); 93 | 94 | foreach ($files as $file) { 95 | $paths[] = [ 96 | "path" => str_replace(base_path(DIRECTORY_SEPARATOR), '', $file->getRealPath()), 97 | "isVendor" => str_contains($file->getRealPath(), base_path("vendor")), 98 | "key" => str($file->getRealPath()) 99 | ->replace(realpath($path), "") 100 | ->replace(".blade.php", "") 101 | ->ltrim(DIRECTORY_SEPARATOR) 102 | ->replace(DIRECTORY_SEPARATOR, ".") 103 | ]; 104 | } 105 | 106 | return $paths; 107 | } 108 | }; 109 | 110 | echo json_encode($blade->getAllViews()->merge($blade->getAllComponents())); 111 | `; -------------------------------------------------------------------------------- /src/test-runner/docker-phpunit-command.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import RemotePhpUnitCommand from "./remote-phpunit-command"; 3 | 4 | export default class DockerPhpUnitCommand extends RemotePhpUnitCommand { 5 | get paths(): { 6 | [localPath: string]: string; 7 | } { 8 | return this.config.get("docker.paths") || {}; 9 | } 10 | 11 | get dockerCommand() { 12 | if (this.config.get("docker.command")) { 13 | return this.config.get("docker.command"); 14 | } 15 | 16 | const msg = 17 | "No laravel.docker.command was specified in the settings"; 18 | vscode.window.showErrorMessage(msg); 19 | 20 | throw msg; 21 | } 22 | 23 | wrapCommand(command: string) { 24 | if ( 25 | vscode.workspace 26 | .getConfiguration("laravel") 27 | .get("ssh.enable") 28 | ) { 29 | return super.wrapCommand(`${this.dockerCommand} ${command}`); 30 | } 31 | return `${this.dockerCommand} ${command}`; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/test-runner/index.ts: -------------------------------------------------------------------------------- 1 | import { config } from "@src/support/config"; 2 | import * as vscode from "vscode"; 3 | import DockerPhpUnitCommand from "./docker-phpunit-command"; 4 | import PhpUnitCommand from "./phpunit-command"; 5 | import RemotePhpUnitCommand from "./remote-phpunit-command"; 6 | 7 | type Command = PhpUnitCommand | RemotePhpUnitCommand | DockerPhpUnitCommand; 8 | 9 | let globalCommand: Command; 10 | 11 | async function runCommand(command: Command) { 12 | setGlobalCommandInstance(command); 13 | 14 | vscode.window.activeTextEditor || 15 | vscode.window.showErrorMessage( 16 | "Better PHPUnit: open a file to run this command", 17 | ); 18 | 19 | await vscode.commands.executeCommand("workbench.action.terminal.clear"); 20 | await vscode.commands.executeCommand( 21 | "workbench.action.tasks.runTask", 22 | "phpunit: run", 23 | ); 24 | } 25 | 26 | async function runPreviousCommand() { 27 | await vscode.commands.executeCommand("workbench.action.terminal.clear"); 28 | await vscode.commands.executeCommand( 29 | "workbench.action.tasks.runTask", 30 | "phpunit: run", 31 | ); 32 | } 33 | 34 | const setGlobalCommandInstance = (commandInstance: Command) => { 35 | // Store this object globally for the provideTasks, "run-test-previous", and for tests to assert against. 36 | globalCommand = commandInstance; 37 | }; 38 | 39 | const resolveTestRunnerCommand = (options = {}): Command => { 40 | if (config("tests.docker.enabled", false)) { 41 | return new DockerPhpUnitCommand(options); 42 | } 43 | 44 | if (config("tests.ssh.enabled", false)) { 45 | return new RemotePhpUnitCommand(options); 46 | } 47 | 48 | return new PhpUnitCommand(options); 49 | }; 50 | 51 | export const testRunnerCommands = [ 52 | vscode.commands.registerCommand("laravel.run-test", async () => { 53 | await runCommand(resolveTestRunnerCommand()); 54 | }), 55 | 56 | vscode.commands.registerCommand("laravel.run-test-file", async () => { 57 | await runCommand(resolveTestRunnerCommand({ runFile: true })); 58 | }), 59 | 60 | vscode.commands.registerCommand("laravel.run-test-suite", async () => { 61 | await runCommand(resolveTestRunnerCommand({ runFullSuite: true })); 62 | }), 63 | 64 | vscode.commands.registerCommand("laravel.run-test-previous", async () => { 65 | await runPreviousCommand(); 66 | }), 67 | 68 | vscode.tasks.registerTaskProvider("phpunit", { 69 | provideTasks: () => { 70 | if (!globalCommand) { 71 | return []; 72 | } 73 | 74 | return [ 75 | new vscode.Task( 76 | { type: "phpunit", task: "run" }, 77 | vscode.TaskScope.Workspace, 78 | "run", 79 | "phpunit", 80 | new vscode.ShellExecution(globalCommand.output), 81 | "$phpunit", 82 | ), 83 | ]; 84 | }, 85 | resolveTask: function ( 86 | task: vscode.Task, 87 | token: vscode.CancellationToken, 88 | ): vscode.ProviderResult { 89 | throw new Error("Function not implemented."); 90 | }, 91 | }), 92 | ]; 93 | -------------------------------------------------------------------------------- /src/test-runner/remote-phpunit-command.ts: -------------------------------------------------------------------------------- 1 | import * as os from "os"; 2 | import * as path from "path"; 3 | import * as vscode from "vscode"; 4 | import PhpUnitCommand from "./phpunit-command"; 5 | 6 | export default class RemotePhpUnitCommand extends PhpUnitCommand { 7 | protected config: vscode.WorkspaceConfiguration; 8 | 9 | constructor( 10 | options: { 11 | runFullSuite?: boolean; 12 | runFile?: boolean; 13 | subDirectory?: string; 14 | } = {}, 15 | ) { 16 | super(options); 17 | 18 | this.config = vscode.workspace.getConfiguration("laravel"); 19 | } 20 | 21 | get file() { 22 | return this.remapLocalPath(super.file); 23 | } 24 | 25 | get binary() { 26 | return this.remapLocalPath(super.binary); 27 | } 28 | 29 | get output() { 30 | return this.wrapCommand(super.output); 31 | } 32 | 33 | get configuration() { 34 | return this.subDirectory 35 | ? " --configuration " + 36 | this.remapLocalPath( 37 | this._normalizePath( 38 | path.join(this.subDirectory, "phpunit.xml"), 39 | ), 40 | ) 41 | : ""; 42 | } 43 | 44 | get paths(): { 45 | [localPath: string]: string; 46 | } { 47 | return this.config.get("ssh.paths") || {}; 48 | } 49 | 50 | get sshBinary() { 51 | if (this.config.get("ssh.binary")) { 52 | return this.config.get("ssh.binary"); 53 | } 54 | 55 | return "ssh"; 56 | } 57 | 58 | remapLocalPath(actualPath: string) { 59 | for (const [localPath, remotePath] of Object.entries(this.paths)) { 60 | const expandedLocalPath = localPath.replace(/^~/, os.homedir()); 61 | if (actualPath.startsWith(expandedLocalPath)) { 62 | return actualPath.replace(expandedLocalPath, remotePath); 63 | } 64 | } 65 | 66 | return actualPath; 67 | } 68 | 69 | wrapCommand(command: string) { 70 | const user = this.config.get("ssh.user"); 71 | const port = this.config.get("ssh.port"); 72 | const host = this.config.get("ssh.host"); 73 | let options = this.config.get("ssh.options"); 74 | let disableOptions = this.config.get("ssh.disableAllOptions"); 75 | let optionsString = ""; 76 | 77 | if (!disableOptions) { 78 | if (!options) { 79 | options = `-tt -p${port}`; 80 | } 81 | optionsString = options + ` ${user}@${host} `; 82 | } 83 | 84 | return `${this.sshBinary} ${optionsString}"${command}"`; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export namespace AutocompleteParsingResult { 2 | export type ContextValue = 3 | | Argument 4 | | Arguments 5 | | ArrayItem 6 | | ArrayValue 7 | | Assignment 8 | | AssignmentValue 9 | | Base 10 | | ClassDefinition 11 | | ClosureValue 12 | | MethodCall 13 | | MethodDefinition 14 | | ObjectValue 15 | | Parameter 16 | | ParameterValue 17 | | Parameters 18 | | StringValue; 19 | 20 | export interface Argument { 21 | type: "argument"; 22 | parent: ContextValue | null; 23 | children: ContextValue[]; 24 | name: string | null; 25 | } 26 | 27 | export interface Arguments { 28 | type: "arguments"; 29 | parent: ContextValue | null; 30 | children: ContextValue[]; 31 | autocompletingIndex: number; 32 | } 33 | 34 | export interface ArrayItem { 35 | type: "array_item"; 36 | parent: ContextValue | null; 37 | children: ContextValue[]; 38 | hasKey: boolean; 39 | autocompletingValue: boolean; 40 | key: StringValue; 41 | value: ContextValue; 42 | } 43 | 44 | export interface ArrayValue { 45 | type: "array"; 46 | parent: ContextValue | null; 47 | children: ArrayItem[]; 48 | } 49 | 50 | export interface Assignment { 51 | type: "assignment"; 52 | parent: ContextValue | null; 53 | name: string | null; 54 | value: AssignmentValue[]; 55 | } 56 | 57 | export interface AssignmentValue { 58 | type: "assignment_value"; 59 | parent: ContextValue | null; 60 | children: ContextValue[]; 61 | } 62 | 63 | export interface Base { 64 | type: "base"; 65 | parent: ContextValue | null; 66 | children: ContextValue[]; 67 | } 68 | 69 | export interface ClassDefinition { 70 | type: "classDefinition"; 71 | parent: ContextValue | null; 72 | children: ContextValue[]; 73 | className: string | null; 74 | extends: string | null; 75 | implements: string[]; 76 | properties: { 77 | name: string; 78 | types: string[]; 79 | }[]; 80 | } 81 | 82 | export interface ClosureValue { 83 | type: "closure"; 84 | parent: ContextValue | null; 85 | children: ContextValue[]; 86 | parameters: Parameters; 87 | } 88 | 89 | export interface MethodCall { 90 | type: "methodCall"; 91 | parent: ContextValue | null; 92 | children: ContextValue[]; 93 | methodName: string | null; 94 | className: string | null; 95 | arguments: Arguments; 96 | } 97 | 98 | export interface MethodDefinition { 99 | type: "methodDefinition"; 100 | parent: ContextValue | null; 101 | children: ContextValue[]; 102 | parameters: Parameters; 103 | methodName: string | null; 104 | } 105 | 106 | export interface ObjectValue { 107 | type: "object"; 108 | parent: ContextValue | null; 109 | children: ContextValue[]; 110 | className: string | null; 111 | arguments: Arguments; 112 | } 113 | 114 | export interface Parameter { 115 | type: "parameter"; 116 | parent: ContextValue | null; 117 | name: string | null; 118 | types: string[]; 119 | } 120 | 121 | export interface ParameterValue { 122 | type: "parameter_value"; 123 | parent: ContextValue | null; 124 | children: ContextValue[]; 125 | } 126 | 127 | export interface Parameters { 128 | type: "parameters"; 129 | parent: ContextValue | null; 130 | children: ContextValue[]; 131 | } 132 | 133 | export interface StringValue { 134 | type: "string"; 135 | parent: ContextValue | null; 136 | value: string; 137 | start?: { 138 | line: number; 139 | column: number; 140 | }; 141 | end?: { 142 | line: number; 143 | column: number; 144 | }; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "Node16", 4 | "target": "ES2022", 5 | "outDir": "out", 6 | "lib": ["ES2022"], 7 | "sourceMap": true, 8 | "rootDir": "src", 9 | "strict": true /* enable all strict type-checking options */, 10 | /* Additional Checks */ 11 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 12 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 13 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 14 | "baseUrl": ".", 15 | "paths": { 16 | "@src/*": ["src/*"] 17 | } 18 | }, 19 | "exclude": ["server"] 20 | } 21 | -------------------------------------------------------------------------------- /vsc-extension-quickstart.md: -------------------------------------------------------------------------------- 1 | # Welcome to your VS Code Extension 2 | 3 | ## What's in the folder 4 | 5 | * This folder contains all of the files necessary for your extension. 6 | * `package.json` - this is the manifest file in which you declare your extension and command. 7 | * The sample plugin registers a command and defines its title and command name. With this information VS Code can show the command in the command palette. It doesn’t yet need to load the plugin. 8 | * `src/extension.ts` - this is the main file where you will provide the implementation of your command. 9 | * The file exports one function, `activate`, which is called the very first time your extension is activated (in this case by executing the command). Inside the `activate` function we call `registerCommand`. 10 | * We pass the function containing the implementation of the command as the second parameter to `registerCommand`. 11 | 12 | ## Get up and running straight away 13 | 14 | * Press `F5` to open a new window with your extension loaded. 15 | * Run your command from the command palette by pressing (`Ctrl+Shift+P` or `Cmd+Shift+P` on Mac) and typing `Hello World`. 16 | * Set breakpoints in your code inside `src/extension.ts` to debug your extension. 17 | * Find output from your extension in the debug console. 18 | 19 | ## Make changes 20 | 21 | * You can relaunch the extension from the debug toolbar after changing code in `src/extension.ts`. 22 | * You can also reload (`Ctrl+R` or `Cmd+R` on Mac) the VS Code window with your extension to load your changes. 23 | 24 | ## Explore the API 25 | 26 | * You can open the full set of our API when you open the file `node_modules/@types/vscode/index.d.ts`. 27 | 28 | ## Run tests 29 | 30 | * Install the [Extension Test Runner](https://marketplace.visualstudio.com/items?itemName=ms-vscode.extension-test-runner) 31 | * Run the "watch" task via the **Tasks: Run Task** command. Make sure this is running, or tests might not be discovered. 32 | * Open the Testing view from the activity bar and click the Run Test" button, or use the hotkey `Ctrl/Cmd + ; A` 33 | * See the output of the test result in the Test Results view. 34 | * Make changes to `src/test/extension.test.ts` or create new test files inside the `test` folder. 35 | * The provided test runner will only consider files matching the name pattern `**.test.ts`. 36 | * You can create folders inside the `test` folder to structure your tests any way you want. 37 | 38 | ## Go further 39 | 40 | * [Follow UX guidelines](https://code.visualstudio.com/api/ux-guidelines/overview) to create extensions that seamlessly integrate with VS Code's native interface and patterns. 41 | * Reduce the extension size and improve the startup time by [bundling your extension](https://code.visualstudio.com/api/working-with-extensions/bundling-extension). 42 | * [Publish your extension](https://code.visualstudio.com/api/working-with-extensions/publishing-extension) on the VS Code extension marketplace. 43 | * Automate builds by setting up [Continuous Integration](https://code.visualstudio.com/api/working-with-extensions/continuous-integration). 44 | --------------------------------------------------------------------------------