From 5c91edd14738a00ae66a7fab2d0d12a464cb616d Mon Sep 17 00:00:00 2001 From: Maurice Hadamczyk Date: Mon, 24 Aug 2026 07:34:12 +0200 Subject: [PATCH 01/13] Add laravel/lsp as a project dependency Framework-aware LSP for routes, config, env vars, Eloquent, and more, to attach alongside intelephense in the snippet editor. Installed as a normal project dependency (vendor/bin/laravel-lsp) rather than a global tool, mirroring how intelephense already lives in node_modules. Excludes vendor/laravel/lsp/app/ from the classmap: it autoloads its own App\Providers\AppServiceProvider under the same App\ namespace this project uses, which Composer otherwise flags as ambiguous. Co-Authored-By: Claude Sonnet 5 --- composer.json | 6 ++++- composer.lock | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 39ecece..7f99f70 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ "fruitcake/laravel-debugbar": "^4.4", "inertiajs/inertia-laravel": "^3.0", "laravel/framework": "^13.25", + "laravel/lsp": "^0.0.31", "laravel/tinker": "^3.0", "laravel/wayfinder": "^0.1.14", "nunomaduro/essentials": "^1.2" @@ -43,7 +44,10 @@ "App\\": "app/", "Database\\Factories\\": "database/factories/", "Database\\Seeders\\": "database/seeders/" - } + }, + "exclude-from-classmap": [ + "/vendor/laravel/lsp/app/" + ] }, "autoload-dev": { "psr-4": { diff --git a/composer.lock b/composer.lock index 44d97b4..08602d7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "278d2719bfe69b9bfe54244de4369a67", + "content-hash": "43a2ee5caaaaa285173efea49c7bd417", "packages": [ { "name": "brick/math", @@ -1459,6 +1459,68 @@ }, "time": "2026-08-11T13:56:22+00:00" }, + { + "name": "laravel/lsp", + "version": "v0.0.31", + "source": { + "type": "git", + "url": "https://github.com/laravel/lsp.git", + "reference": "6739481f9c16aa628582c69014c083b9abf3987f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/lsp/zipball/6739481f9c16aa628582c69014c083b9abf3987f", + "reference": "6739481f9c16aa628582c69014c083b9abf3987f", + "shasum": "" + }, + "require": { + "php": "^8.2.0" + }, + "require-dev": { + "amphp/amp": "^3.0", + "amphp/byte-stream": "^2.1", + "laravel-zero/framework": "^12.0", + "laravel/pint": "^1.15.2", + "microsoft/tolerant-php-parser": "^0.1.2", + "mockery/mockery": "^1.6.11", + "pestphp/pest": "^4.0", + "stillat/blade-parser": "^2.1" + }, + "bin": [ + "builds/laravel-lsp" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/" + }, + "exclude-from-classmap": [ + "app/Lsp/Data/Templates/global.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laravel" + } + ], + "description": "The Laravel language server.", + "homepage": "https://github.com/laravel/lsp", + "keywords": [ + "LSP", + "language-server", + "laravel", + "php" + ], + "support": { + "issues": "https://github.com/laravel/lsp/issues", + "source": "https://github.com/laravel/lsp" + }, + "time": "2026-08-14T09:30:34+00:00" + }, { "name": "laravel/prompts", "version": "v0.3.22", From 0b54dea5283a0890bb2dcab4671aa90dc3445705 Mon Sep 17 00:00:00 2001 From: Maurice Hadamczyk Date: Mon, 24 Aug 2026 08:00:33 +0200 Subject: [PATCH 02/13] Extract a shared language server bridge core Pulls the https/wss server setup, origin verification, idle timeout, and connection-forwarding logic out of intelephense-bridge.mjs into a reusable runBridge() so a second bridge (for laravel-lsp) doesn't have to duplicate it. Behavior-preserving: intelephense-bridge.mjs becomes a thin caller supplying its own binary, args, and initialize rewrite. Co-Authored-By: Claude Sonnet 5 --- app/Support/bin/intelephense-bridge.mjs | 66 ++-------------------- app/Support/bin/language-server-bridge.mjs | 66 ++++++++++++++++++++++ 2 files changed, 72 insertions(+), 60 deletions(-) create mode 100644 app/Support/bin/language-server-bridge.mjs diff --git a/app/Support/bin/intelephense-bridge.mjs b/app/Support/bin/intelephense-bridge.mjs index f8ac0b3..39d35ab 100644 --- a/app/Support/bin/intelephense-bridge.mjs +++ b/app/Support/bin/intelephense-bridge.mjs @@ -1,26 +1,10 @@ -import { readFileSync } from 'node:fs'; -import { createServer } from 'node:https'; -import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { toSocket } from 'vscode-ws-jsonrpc'; -import { createServerProcess, createWebSocketConnection, forward } from 'vscode-ws-jsonrpc/server'; -import { WebSocketServer } from 'ws'; +import { runBridge } from './language-server-bridge.mjs'; -const IDLE_TIMEOUT_MS = 5 * 60 * 1000; const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); const INTELEPHENSE_BIN = path.join(PROJECT_ROOT, 'node_modules/.bin/intelephense'); -// Safari (macOS 15+) blocks a plain ws:// connection from an https:// page as mixed content, even to -// 127.0.0.1, unlike Chrome/Firefox. Reusing tinkerbench.test's own Herd-issued certificate (signed by the -// already system-trusted Laravel Valet CA) lets the bridge speak wss:// without minting a new certificate. -// TINKERBENCH_HERD_CERTIFICATES_DIR overrides this for environments without Herd (CI), the same way -// services.herd.nvm_exec is already overridable. -const HERD_CERTIFICATES_DIR = process.env.TINKERBENCH_HERD_CERTIFICATES_DIR ?? path.join( - os.homedir(), - 'Library/Application Support/Herd/config/valet/Certificates', -); - const [, , projectPath, phpVersion] = process.argv; if (!projectPath || !phpVersion) { @@ -28,25 +12,6 @@ if (!projectPath || !phpVersion) { process.exit(1); } -const httpsServer = createServer({ - cert: readFileSync(path.join(HERD_CERTIFICATES_DIR, 'tinkerbench.test.crt')), - key: readFileSync(path.join(HERD_CERTIFICATES_DIR, 'tinkerbench.test.key')), -}); -// Rejects any WebSocket handshake not sent from tinkerbench's own page, so another origin open in the -// same browser can't drive this bridge's LSP session (cross-site WebSocket hijacking): unlike fetch(), -// browsers always send Origin on a WS handshake regardless of same-origin, so this is a reliable check. -const server = new WebSocketServer({ - server: httpsServer, - verifyClient: ({ origin }) => origin === 'https://tinkerbench.test', -}); - -let idleTimer = null; - -function resetIdleTimer() { - clearTimeout(idleTimer); - idleTimer = setTimeout(() => process.exit(0), IDLE_TIMEOUT_MS); -} - // intelephense's PHP version target isn't an "initialize" param, it only takes effect through a // workspace/didChangeConfiguration notification, so it has to be injected as a side effect once the // client's own "initialized" notification passes through, rather than returned from the map itself. @@ -68,28 +33,9 @@ function rewriteToTargetProject(message, serverConnection) { return message; } -httpsServer.once('listening', () => { - process.stdout.write(`${httpsServer.address().port}\n`); - resetIdleTimer(); -}); - -httpsServer.listen(0, '127.0.0.1'); - -server.on('connection', (socket) => { - clearTimeout(idleTimer); - - socket.on('close', () => process.exit(0)); - socket.on('message', () => resetIdleTimer()); - - const serverConnection = createServerProcess('intelephense', INTELEPHENSE_BIN, ['--stdio']); - - if (!serverConnection) { - socket.close(); - - return; - } - - forward(createWebSocketConnection(toSocket(socket)), serverConnection, (message) => - rewriteToTargetProject(message, serverConnection), - ); +runBridge({ + serverName: 'intelephense', + spawnBin: INTELEPHENSE_BIN, + spawnArgs: ['--stdio'], + rewriteMessage: rewriteToTargetProject, }); diff --git a/app/Support/bin/language-server-bridge.mjs b/app/Support/bin/language-server-bridge.mjs new file mode 100644 index 0000000..8fa42cb --- /dev/null +++ b/app/Support/bin/language-server-bridge.mjs @@ -0,0 +1,66 @@ +import { readFileSync } from 'node:fs'; +import { createServer } from 'node:https'; +import os from 'node:os'; +import path from 'node:path'; +import { toSocket } from 'vscode-ws-jsonrpc'; +import { createServerProcess, createWebSocketConnection, forward } from 'vscode-ws-jsonrpc/server'; +import { WebSocketServer } from 'ws'; + +const IDLE_TIMEOUT_MS = 5 * 60 * 1000; + +// Safari (macOS 15+) blocks a plain ws:// connection from an https:// page as mixed content, even to +// 127.0.0.1, unlike Chrome/Firefox. Reusing tinkerbench.test's own Herd-issued certificate (signed by the +// already system-trusted Laravel Valet CA) lets the bridge speak wss:// without minting a new certificate. +// TINKERBENCH_HERD_CERTIFICATES_DIR overrides this for environments without Herd (CI), the same way +// services.herd.nvm_exec is already overridable. +const HERD_CERTIFICATES_DIR = process.env.TINKERBENCH_HERD_CERTIFICATES_DIR ?? path.join( + os.homedir(), + 'Library/Application Support/Herd/config/valet/Certificates', +); + +export function runBridge({ serverName, spawnBin, spawnArgs, rewriteMessage }) { + const httpsServer = createServer({ + cert: readFileSync(path.join(HERD_CERTIFICATES_DIR, 'tinkerbench.test.crt')), + key: readFileSync(path.join(HERD_CERTIFICATES_DIR, 'tinkerbench.test.key')), + }); + // Rejects any WebSocket handshake not sent from tinkerbench's own page, so another origin open in the + // same browser can't drive this bridge's LSP session (cross-site WebSocket hijacking): unlike fetch(), + // browsers always send Origin on a WS handshake regardless of same-origin, so this is a reliable check. + const server = new WebSocketServer({ + server: httpsServer, + verifyClient: ({ origin }) => origin === 'https://tinkerbench.test', + }); + + let idleTimer = null; + + function resetIdleTimer() { + clearTimeout(idleTimer); + idleTimer = setTimeout(() => process.exit(0), IDLE_TIMEOUT_MS); + } + + httpsServer.once('listening', () => { + process.stdout.write(`${httpsServer.address().port}\n`); + resetIdleTimer(); + }); + + httpsServer.listen(0, '127.0.0.1'); + + server.on('connection', (socket) => { + clearTimeout(idleTimer); + + socket.on('close', () => process.exit(0)); + socket.on('message', () => resetIdleTimer()); + + const serverConnection = createServerProcess(serverName, spawnBin, spawnArgs); + + if (!serverConnection) { + socket.close(); + + return; + } + + forward(createWebSocketConnection(toSocket(socket)), serverConnection, (message) => + rewriteMessage(message, serverConnection), + ); + }); +} From 3a5eea476ca5366a71cba149c486f2ec92d1b3c2 Mon Sep 17 00:00:00 2001 From: Maurice Hadamczyk Date: Mon, 24 Aug 2026 08:12:17 +0200 Subject: [PATCH 03/13] Add the LaravelLspBridge spawn path Mirrors LanguageServerBridge/intelephense-bridge.mjs exactly, on top of the shared runBridge() core: spawns vendor/bin/laravel-lsp, rewrites the initialize request's rootUri/rootPath/workspaceFolders to the target project, and sets initializationOptions (phpEnvironment: herd; pestGenerateDocBlocks disabled so attaching never writes into the target project's storage/ directory). No HTTP endpoint yet - that's the next task. Co-Authored-By: Claude Sonnet 5 --- app/Support/LaravelLspBridge.php | 55 +++++++++++++++++++++ app/Support/bin/laravel-lsp-bridge.mjs | 33 +++++++++++++ tests/Unit/Support/LaravelLspBridgeTest.php | 50 +++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 app/Support/LaravelLspBridge.php create mode 100644 app/Support/bin/laravel-lsp-bridge.mjs create mode 100644 tests/Unit/Support/LaravelLspBridgeTest.php diff --git a/app/Support/LaravelLspBridge.php b/app/Support/LaravelLspBridge.php new file mode 100644 index 0000000..00792fd --- /dev/null +++ b/app/Support/LaravelLspBridge.php @@ -0,0 +1,55 @@ + true])->timeout(self::START_TIMEOUT_SECONDS)->start([ + $this->nvmExec(), + 'node', + base_path('app/Support/bin/laravel-lsp-bridge.mjs'), + $projectPath, + ]); + + $port = null; + + $invoked->waitUntil(function (string $type, string $line) use (&$port): bool { + if ($type !== 'out') { + return false; + } + + $port = (int) mb_trim($line); + + return true; + }); + + throw_unless(is_int($port) && $port >= 1 && $port <= 65535, InvalidArgumentException::class, 'The language server bridge did not report a valid port.'); + + return $port; + } + + private function nvmExec(): string + { + $path = config('services.herd.nvm_exec'); + + throw_if(! is_string($path) || $path === '', InvalidArgumentException::class, 'The services.herd.nvm_exec configuration must be a non-empty path.'); + + return $path; + } +} diff --git a/app/Support/bin/laravel-lsp-bridge.mjs b/app/Support/bin/laravel-lsp-bridge.mjs new file mode 100644 index 0000000..fce55d1 --- /dev/null +++ b/app/Support/bin/laravel-lsp-bridge.mjs @@ -0,0 +1,33 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runBridge } from './language-server-bridge.mjs'; + +const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const LARAVEL_LSP_BIN = path.join(PROJECT_ROOT, 'vendor/bin/laravel-lsp'); + +const [, , projectPath] = process.argv; + +if (!projectPath) { + console.error('Usage: laravel-lsp-bridge.mjs '); + process.exit(1); +} + +function rewriteToTargetProject(message) { + if (message.method === 'initialize' && message.params) { + message.params.rootUri = `file://${projectPath}`; + message.params.rootPath = projectPath; + message.params.workspaceFolders = [{ uri: `file://${projectPath}`, name: 'project' }]; + // pestGenerateDocBlocks defaults to true and writes storage/framework/testing/_pest.php into the + // target project on disk, a side effect this bridge shouldn't cause just by attaching. + message.params.initializationOptions = { phpEnvironment: 'herd', pestGenerateDocBlocks: false }; + } + + return message; +} + +runBridge({ + serverName: 'laravel-lsp', + spawnBin: LARAVEL_LSP_BIN, + spawnArgs: [], + rewriteMessage: rewriteToTargetProject, +}); diff --git a/tests/Unit/Support/LaravelLspBridgeTest.php b/tests/Unit/Support/LaravelLspBridgeTest.php new file mode 100644 index 0000000..318e91d --- /dev/null +++ b/tests/Unit/Support/LaravelLspBridgeTest.php @@ -0,0 +1,50 @@ +start(sys_get_temp_dir()); + + expect($port)->toBeGreaterThan(0); + + $connected = Process::run([ + config('services.herd.nvm_exec'), + 'node', + '-e', + // rejectUnauthorized: false only skips certificate verification for this connectivity check, the + // certificate chain itself is verified separately (it's tinkerbench.test's own Herd-issued certificate). + "const ws = new (require('ws'))('wss://tinkerbench.test:{$port}', { rejectUnauthorized: false, headers: { Origin: 'https://tinkerbench.test' } }); ". + "ws.on('open', () => { process.stdout.write('connected'); ws.close(); }); ". + "ws.on('error', (error) => { process.stderr.write(String(error)); process.exitCode = 1; });", + ]); + + expect($connected->output())->toContain('connected'); +}); + +it('rejects a websocket handshake from another origin', function (): void { + $port = new LaravelLspBridge()->start(sys_get_temp_dir()); + + $connected = Process::run([ + config('services.herd.nvm_exec'), + 'node', + '-e', + "const ws = new (require('ws'))('wss://tinkerbench.test:{$port}', { rejectUnauthorized: false, headers: { Origin: 'https://evil.test' } }); ". + "ws.on('open', () => { process.stdout.write('connected'); ws.close(); }); ". + "ws.on('unexpected-response', () => { process.stdout.write('rejected'); });", + ]); + + expect($connected->output())->toContain('rejected'); +}); + +it('throws when the herd Node runtime is not configured', function (): void { + config(['services.herd.nvm_exec' => null]); + + new LaravelLspBridge()->start(sys_get_temp_dir()); +})->throws(InvalidArgumentException::class); + +it('throws when the bridge script does not report a port', function (): void { + new LaravelLspBridge()->start(''); +})->throws(InvalidArgumentException::class); From 21ad21882d3cb3ce66e2ff13b3160798038abba4 Mon Sep 17 00:00:00 2001 From: Maurice Hadamczyk Date: Mon, 24 Aug 2026 08:23:32 +0200 Subject: [PATCH 04/13] Add the Laravel LSP HTTP endpoint POST api/projects/{project}/laravel-language-server, mirroring the existing intelephense endpoint exactly: resolves the Herd project path, starts the LaravelLspBridge, and returns its port as JSON. Verified against the live app: a real request against the "demo" Herd project returns {"port": ...} with a 200. Co-Authored-By: Claude Sonnet 5 --- .../StartLaravelLanguageServerAction.php | 23 +++++++++++++++ .../StartLaravelLanguageServerController.php | 16 ++++++++++ routes/web.php | 4 +++ ...artLaravelLanguageServerControllerTest.php | 28 ++++++++++++++++++ .../StartLaravelLanguageServerActionTest.php | 29 +++++++++++++++++++ 5 files changed, 100 insertions(+) create mode 100644 app/Actions/StartLaravelLanguageServerAction.php create mode 100644 app/Http/Controllers/StartLaravelLanguageServerController.php create mode 100644 tests/Http/StartLaravelLanguageServerControllerTest.php create mode 100644 tests/Unit/Actions/StartLaravelLanguageServerActionTest.php diff --git a/app/Actions/StartLaravelLanguageServerAction.php b/app/Actions/StartLaravelLanguageServerAction.php new file mode 100644 index 0000000..93c2c9a --- /dev/null +++ b/app/Actions/StartLaravelLanguageServerAction.php @@ -0,0 +1,23 @@ +herd->projectPath($project); + + throw_if($projectPath === null, RuntimeException::class, "Unknown Herd project: {$project}"); + + return $this->bridge->start($projectPath); + } +} diff --git a/app/Http/Controllers/StartLaravelLanguageServerController.php b/app/Http/Controllers/StartLaravelLanguageServerController.php new file mode 100644 index 0000000..907382d --- /dev/null +++ b/app/Http/Controllers/StartLaravelLanguageServerController.php @@ -0,0 +1,16 @@ +json(['port' => $action->execute($project)]); + } +} diff --git a/routes/web.php b/routes/web.php index f6d6077..6f23b22 100644 --- a/routes/web.php +++ b/routes/web.php @@ -9,6 +9,7 @@ use App\Http\Controllers\OpenSnippetController; use App\Http\Controllers\RunSnippetController; use App\Http\Controllers\StartLanguageServerController; +use App\Http\Controllers\StartLaravelLanguageServerController; use App\Http\Controllers\UpdateSnippetContentController; use App\Http\Controllers\UpdateSnippetNameController; use App\Http\Middleware\EnsureKnownProject; @@ -27,6 +28,9 @@ Route::post('api/projects/{project}/language-server', StartLanguageServerController::class) ->middleware(EnsureKnownProject::class); +Route::post('api/projects/{project}/laravel-language-server', StartLaravelLanguageServerController::class) + ->middleware(EnsureKnownProject::class); + Route::prefix('api/projects/{project}/snippets')->middleware(EnsureKnownProject::class)->group(function (): void { Route::get('/', ListSnippetsController::class); Route::post('/', CreateSnippetController::class)->middleware(HandlePrecognitiveRequests::class); diff --git a/tests/Http/StartLaravelLanguageServerControllerTest.php b/tests/Http/StartLaravelLanguageServerControllerTest.php new file mode 100644 index 0000000..0d98940 --- /dev/null +++ b/tests/Http/StartLaravelLanguageServerControllerTest.php @@ -0,0 +1,28 @@ +mock(StartLaravelLanguageServerAction::class) + ->shouldReceive('execute')->once()->with('my-project')->andReturn(54213); + + app()->call(new StartLaravelLanguageServerController(), ['project' => 'my-project']); +}); + +it('uses the right middleware', function (): void { + expect(StartLaravelLanguageServerController::class)->toUseMiddleware(EnsureKnownProject::class); +}); + +it('returns the port for a known project', function (): void { + mockKnownProject(); + + $this->mock(StartLaravelLanguageServerAction::class)->shouldReceive('execute')->andReturn(54213); + + $this->postJson('/api/projects/my-project/laravel-language-server') + ->assertOk() + ->assertExactJson(['port' => 54213]); +}); diff --git a/tests/Unit/Actions/StartLaravelLanguageServerActionTest.php b/tests/Unit/Actions/StartLaravelLanguageServerActionTest.php new file mode 100644 index 0000000..0b5c8e1 --- /dev/null +++ b/tests/Unit/Actions/StartLaravelLanguageServerActionTest.php @@ -0,0 +1,29 @@ +mock(Herd::class, function (MockInterface $mock): void { + $mock->shouldReceive('projectPath')->once()->with('other-project')->andReturn('/path/to/other-project'); + }); + $this->mock(LaravelLspBridge::class, function (MockInterface $mock): void { + $mock->shouldReceive('start')->once()->with('/path/to/other-project')->andReturn(54213); + }); + + $port = resolve(StartLaravelLanguageServerAction::class)->execute('other-project'); + + expect($port)->toBe(54213); +}); + +it('throws when the given project is unknown to herd', function (): void { + $this->mock(Herd::class, function (MockInterface $mock): void { + $mock->shouldReceive('projectPath')->once()->with('unknown')->andReturn(null); + }); + + resolve(StartLaravelLanguageServerAction::class)->execute('unknown'); +})->throws(RuntimeException::class); From 08949e0c997a711d15c506cf077321cd2eab7b97 Mon Sep 17 00:00:00 2001 From: Maurice Hadamczyk Date: Mon, 24 Aug 2026 08:31:34 +0200 Subject: [PATCH 05/13] Generalize attachLanguageServer() to accept a per-server config Replaces the project-string parameter with a config object carrying what now differs between servers: the port-request URL, the Monaco marker owner key, and optional initializationOptions merged into the initialize request. Completion/hover/signatureHelp registration, the JSON-RPC plumbing, and the WebSocket lifecycle stay shared - this is a signature change, not new logic, so it can be called a second time for laravel-lsp without duplicating any of that. MonacoEditor.vue's single existing call site is updated to the new shape in this same commit (still only the one intelephense call) so the build isn't left broken until the second call is added next. Co-Authored-By: Claude Sonnet 5 --- resources/js/components/MonacoEditor.test.ts | 5 +- resources/js/components/MonacoEditor.vue | 6 +- resources/js/lib/languageServer.test.ts | 120 +++++++++++++++---- resources/js/lib/languageServer.ts | 20 ++-- 4 files changed, 122 insertions(+), 29 deletions(-) diff --git a/resources/js/components/MonacoEditor.test.ts b/resources/js/components/MonacoEditor.test.ts index 951494e..3b48961 100644 --- a/resources/js/components/MonacoEditor.test.ts +++ b/resources/js/components/MonacoEditor.test.ts @@ -181,7 +181,10 @@ it('attaches the language server for the current project', () => { expect(attachLanguageServer).toHaveBeenCalledWith( monaco, - 'my-project', + { + requestPortUrl: '/api/projects/my-project/language-server', + ownerKey: 'intelephense', + }, ' import * as monaco from 'monaco-editor'; import { onBeforeUnmount, onMounted, useTemplateRef, watch } from 'vue'; +import StartLanguageServerController from '@/actions/App/Http/Controllers/StartLanguageServerController'; import { useTheme } from '@/composables/useTheme'; import { attachLanguageServer } from '@/lib/languageServer'; import type { LanguageServerHandle } from '@/lib/languageServer'; @@ -96,7 +97,10 @@ onMounted(() => { attachLanguageServer( monaco, - props.project, + { + requestPortUrl: StartLanguageServerController.url(props.project), + ownerKey: 'intelephense', + }, props.initialValue, editor.getModel()!, ) diff --git a/resources/js/lib/languageServer.test.ts b/resources/js/lib/languageServer.test.ts index fa49eb5..8c653be 100644 --- a/resources/js/lib/languageServer.test.ts +++ b/resources/js/lib/languageServer.test.ts @@ -71,6 +71,11 @@ class FakeWebSocket { const model = {} as Monaco.editor.ITextModel; +const intelephenseConfig = { + requestPortUrl: '/api/projects/customer-portal/language-server', + ownerKey: 'intelephense', +}; + const monaco = { editor: { setModelMarkers: vi.fn(), @@ -125,7 +130,7 @@ async function connectAndHandshake(): Promise { it('requests a port for the project and opens a WebSocket to it', async () => { const attaching = attachLanguageServer( monaco, - 'customer-portal', + intelephenseConfig, ' { it('sends the initial document content once the connection is ready', async () => { const attaching = attachLanguageServer( monaco, - 'customer-portal', + intelephenseConfig, ' { const attaching = attachLanguageServer( monaco, - 'customer-portal', + intelephenseConfig, ' { const attaching = attachLanguageServer( monaco, - 'customer-portal', + intelephenseConfig, ' { ); await expect( - attachLanguageServer(monaco, 'customer-portal', ' { ); await expect( - attachLanguageServer(monaco, 'customer-portal', ' { const attaching = attachLanguageServer( monaco, - 'customer-portal', + intelephenseConfig, ' it('rejects a pending request instead of leaving it stuck forever when the connection closes', async () => { const attaching = attachLanguageServer( monaco, - 'customer-portal', + intelephenseConfig, ' { const attaching = attachLanguageServer( monaco, - 'customer-portal', + intelephenseConfig, ' { const attaching = attachLanguageServer( monaco, - 'customer-portal', + intelephenseConfig, ' { const attaching = attachLanguageServer( monaco, - 'customer-portal', + intelephenseConfig, ' { const attaching = attachLanguageServer( monaco, - 'customer-portal', + intelephenseConfig, ' { it('returns no hover when the language server has nothing to show', async () => { const attaching = attachLanguageServer( monaco, - 'customer-portal', + intelephenseConfig, ' it('returns the active signature and parameter reported by the language server', async () => { const attaching = attachLanguageServer( monaco, - 'customer-portal', + intelephenseConfig, ' { const attaching = attachLanguageServer( monaco, - 'customer-portal', + intelephenseConfig, ' { const attaching = attachLanguageServer( monaco, - 'customer-portal', + intelephenseConfig, ' { it('declares support for diagnostics when initializing', async () => { const attaching = attachLanguageServer( monaco, - 'customer-portal', + intelephenseConfig, ' { it('applies diagnostics from the language server as Monaco markers', async () => { const attaching = attachLanguageServer( monaco, - 'customer-portal', + intelephenseConfig, ' it('defaults a diagnostic with no reported severity to an error marker', async () => { const attaching = attachLanguageServer( monaco, - 'customer-portal', + intelephenseConfig, ' { const attaching = attachLanguageServer( monaco, - 'customer-portal', + intelephenseConfig, ' { [], ); }); + +it('requests a port from the configured URL and applies diagnostics under the configured owner key', async () => { + const attaching = attachLanguageServer( + monaco, + { + requestPortUrl: + '/api/projects/customer-portal/laravel-language-server', + ownerKey: 'laravel-lsp', + }, + ' { + const attaching = attachLanguageServer( + monaco, + { + requestPortUrl: + '/api/projects/customer-portal/laravel-language-server', + ownerKey: 'laravel-lsp', + initializationOptions: { + phpEnvironment: 'herd', + pestGenerateDocBlocks: false, + }, + }, + '; + ownerKey: string; + requestPortUrl: string; +} + export interface LanguageServerHandle { dispose(): void; notifyContentChanged(content: string): void; } -async function requestPort(project: string): Promise { - const response = await fetch(StartLanguageServerController.url(project), { +async function requestPort(requestPortUrl: string): Promise { + const response = await fetch(requestPortUrl, { method: 'POST', headers: xsrfHeader(), }); @@ -157,11 +162,11 @@ function toMonacoRange( export async function attachLanguageServer( monaco: typeof Monaco, - project: string, + config: LanguageServerConfig, initialContent: string, model: Monaco.editor.ITextModel, ): Promise { - const port = await requestPort(project); + const port = await requestPort(config.requestPortUrl); // Safari (macOS 15+) blocks a plain ws:// connection from an https:// page as mixed content, even to // 127.0.0.1, unlike Chrome/Firefox. wss:// against tinkerbench.test itself (not window.location.hostname, // browser tests may serve the page from a plain http://127.0.0.1 test server) matches the certificate the @@ -259,7 +264,7 @@ export async function attachLanguageServer( monaco.editor.setModelMarkers( model, - 'intelephense', + config.ownerKey, diagnostics.map((diagnostic) => ({ ...toMonacoRange(diagnostic.range, { lineNumber: 1, @@ -312,6 +317,7 @@ export async function attachLanguageServer( await request('initialize', { processId: null, rootUri: null, + initializationOptions: config.initializationOptions, capabilities: { textDocument: { completion: { @@ -505,7 +511,7 @@ export async function attachLanguageServer( completionProvider.dispose(); hoverProvider.dispose(); signatureHelpProvider.dispose(); - monaco.editor.setModelMarkers(model, 'intelephense', []); + monaco.editor.setModelMarkers(model, config.ownerKey, []); rejectPendingRequests( new Error('The language server was disposed.'), ); From 5d7d29d3856b78c1a3923e2bfe7cd93b46881056 Mon Sep 17 00:00:00 2001 From: Maurice Hadamczyk Date: Mon, 24 Aug 2026 09:27:41 +0200 Subject: [PATCH 06/13] Fix laravel-lsp bridge invocation to use a resolved PHP binary laravel-lsp is a #!/usr/bin/env php script, unlike intelephense which is self-contained JS. When the bridge is spawned from a real HTTP request (Herd's web server process), its PATH doesn't reliably resolve `php` the way an interactive shell does, so the shebang fails silently and the child process dies immediately after the WebSocket connects - reproduced with a live request against a real Herd project. Resolves the same way this app already resolves any project's PHP binary (Herd::phpBinary(), via `herd which-php`) rather than PHP's own PHP_BINARY constant, which under PHP-FPM points at the fpm binary itself, not a script-capable CLI interpreter. Adds a regression test that sends a real initialize request through the bridge and asserts on the response, not just that the socket connects - the existing tests couldn't have caught this, since the wrapping bridge's own WebSocket server accepts a connection before the child process is even spawned. Co-Authored-By: Claude Sonnet 5 --- .../StartLaravelLanguageServerAction.php | 9 +++++- app/Support/LaravelLspBridge.php | 3 +- app/Support/bin/laravel-lsp-bridge.mjs | 12 +++---- .../StartLaravelLanguageServerActionTest.php | 4 ++- tests/Unit/Support/LaravelLspBridgeTest.php | 32 ++++++++++++++++--- 5 files changed, 47 insertions(+), 13 deletions(-) diff --git a/app/Actions/StartLaravelLanguageServerAction.php b/app/Actions/StartLaravelLanguageServerAction.php index 93c2c9a..58d6df0 100644 --- a/app/Actions/StartLaravelLanguageServerAction.php +++ b/app/Actions/StartLaravelLanguageServerAction.php @@ -18,6 +18,13 @@ public function execute(string $project): int throw_if($projectPath === null, RuntimeException::class, "Unknown Herd project: {$project}"); - return $this->bridge->start($projectPath); + // laravel-lsp is a PHP tool, not the target project's own PHP - this app's own PHP binary + // runs it, resolved the same way this app already resolves any project's PHP binary, + // rather than relying on the `#!/usr/bin/env php` shebang. Herd's PATH for web-server + // processes doesn't reliably include `php` (unlike an interactive shell), so an explicit + // binary is required. + $phpBinary = $this->herd->phpBinary($this->herd->currentProject()); + + return $this->bridge->start($projectPath, $phpBinary); } } diff --git a/app/Support/LaravelLspBridge.php b/app/Support/LaravelLspBridge.php index 00792fd..fc53135 100644 --- a/app/Support/LaravelLspBridge.php +++ b/app/Support/LaravelLspBridge.php @@ -11,7 +11,7 @@ class LaravelLspBridge { private const int START_TIMEOUT_SECONDS = 60; - public function start(string $projectPath): int + public function start(string $projectPath, string $phpBinary): int { // This request blocks waiting on the bridge process below, bounded by Process's own // timeout, so PHP's own max_execution_time is lifted past that same bound, with headroom @@ -25,6 +25,7 @@ public function start(string $projectPath): int 'node', base_path('app/Support/bin/laravel-lsp-bridge.mjs'), $projectPath, + $phpBinary, ]); $port = null; diff --git a/app/Support/bin/laravel-lsp-bridge.mjs b/app/Support/bin/laravel-lsp-bridge.mjs index fce55d1..fce9fe0 100644 --- a/app/Support/bin/laravel-lsp-bridge.mjs +++ b/app/Support/bin/laravel-lsp-bridge.mjs @@ -3,12 +3,12 @@ import { fileURLToPath } from 'node:url'; import { runBridge } from './language-server-bridge.mjs'; const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); -const LARAVEL_LSP_BIN = path.join(PROJECT_ROOT, 'vendor/bin/laravel-lsp'); +const LARAVEL_LSP_SCRIPT = path.join(PROJECT_ROOT, 'vendor/bin/laravel-lsp'); -const [, , projectPath] = process.argv; +const [, , projectPath, phpBinary] = process.argv; -if (!projectPath) { - console.error('Usage: laravel-lsp-bridge.mjs '); +if (!projectPath || !phpBinary) { + console.error('Usage: laravel-lsp-bridge.mjs '); process.exit(1); } @@ -27,7 +27,7 @@ function rewriteToTargetProject(message) { runBridge({ serverName: 'laravel-lsp', - spawnBin: LARAVEL_LSP_BIN, - spawnArgs: [], + spawnBin: phpBinary, + spawnArgs: [LARAVEL_LSP_SCRIPT], rewriteMessage: rewriteToTargetProject, }); diff --git a/tests/Unit/Actions/StartLaravelLanguageServerActionTest.php b/tests/Unit/Actions/StartLaravelLanguageServerActionTest.php index 0b5c8e1..0963d59 100644 --- a/tests/Unit/Actions/StartLaravelLanguageServerActionTest.php +++ b/tests/Unit/Actions/StartLaravelLanguageServerActionTest.php @@ -10,9 +10,11 @@ it('starts the laravel lsp bridge for the given project', function (): void { $this->mock(Herd::class, function (MockInterface $mock): void { $mock->shouldReceive('projectPath')->once()->with('other-project')->andReturn('/path/to/other-project'); + $mock->shouldReceive('currentProject')->once()->andReturn('tinkerbench'); + $mock->shouldReceive('phpBinary')->once()->with('tinkerbench')->andReturn('/path/to/tinkerbench/php'); }); $this->mock(LaravelLspBridge::class, function (MockInterface $mock): void { - $mock->shouldReceive('start')->once()->with('/path/to/other-project')->andReturn(54213); + $mock->shouldReceive('start')->once()->with('/path/to/other-project', '/path/to/tinkerbench/php')->andReturn(54213); }); $port = resolve(StartLaravelLanguageServerAction::class)->execute('other-project'); diff --git a/tests/Unit/Support/LaravelLspBridgeTest.php b/tests/Unit/Support/LaravelLspBridgeTest.php index 318e91d..277217a 100644 --- a/tests/Unit/Support/LaravelLspBridgeTest.php +++ b/tests/Unit/Support/LaravelLspBridgeTest.php @@ -6,7 +6,7 @@ use Illuminate\Support\Facades\Process; it('spawns a detached bridge process that survives past the request and reports its port', function (): void { - $port = new LaravelLspBridge()->start(sys_get_temp_dir()); + $port = new LaravelLspBridge()->start(sys_get_temp_dir(), PHP_BINARY); expect($port)->toBeGreaterThan(0); @@ -25,7 +25,7 @@ }); it('rejects a websocket handshake from another origin', function (): void { - $port = new LaravelLspBridge()->start(sys_get_temp_dir()); + $port = new LaravelLspBridge()->start(sys_get_temp_dir(), PHP_BINARY); $connected = Process::run([ config('services.herd.nvm_exec'), @@ -39,12 +39,36 @@ expect($connected->output())->toContain('rejected'); }); +it('responds to a real initialize request through the bridged connection', function (): void { + // laravel-lsp validates that its rootUri (rewritten from $projectPath by the bridge script, + // regardless of what the client sends) is a real Laravel project, so this needs an actual + // one - this project's own root, portable across machines and CI. + $port = new LaravelLspBridge()->start(base_path(), PHP_BINARY); + + // Proves the spawned laravel-lsp process itself is reachable end-to-end, not just that the + // wrapping bridge's own WebSocket server accepts a connection: a WS handshake succeeding + // (the other tests here) says nothing about whether the child process behind it is alive, + // since that process is only spawned once a client actually connects. + $result = Process::run([ + config('services.herd.nvm_exec'), + 'node', + '-e', + "const ws = new (require('ws'))('wss://tinkerbench.test:{$port}', { rejectUnauthorized: false, headers: { Origin: 'https://tinkerbench.test' } }); ". + "ws.on('open', () => ws.send(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { processId: null, rootUri: null, capabilities: {} } }))); ". + "ws.on('message', (data) => { process.stdout.write(data.toString()); ws.close(); }); ". + "ws.on('error', (error) => { process.stderr.write(String(error)); process.exitCode = 1; }); ". + 'setTimeout(() => { process.stderr.write("timed out"); process.exit(1); }, 15000);', + ]); + + expect($result->output())->toContain('"serverInfo"'); +}); + it('throws when the herd Node runtime is not configured', function (): void { config(['services.herd.nvm_exec' => null]); - new LaravelLspBridge()->start(sys_get_temp_dir()); + new LaravelLspBridge()->start(sys_get_temp_dir(), PHP_BINARY); })->throws(InvalidArgumentException::class); it('throws when the bridge script does not report a port', function (): void { - new LaravelLspBridge()->start(''); + new LaravelLspBridge()->start('', PHP_BINARY); })->throws(InvalidArgumentException::class); From 8a5c50a4a6847b1f6ecbd7c66fd122c8fc1cad7e Mon Sep 17 00:00:00 2001 From: Maurice Hadamczyk Date: Mon, 24 Aug 2026 09:27:48 +0200 Subject: [PATCH 07/13] Wire both language servers into MonacoEditor.vue Calls attachLanguageServer twice on mount - once for intelephense (existing), once for laravel-lsp (new, with initializationOptions for phpEnvironment/pestGenerateDocBlocks) - as two independent handles, each with its own .catch(() => undefined). One server failing to attach doesn't affect the other; both get notifyContentChanged on every edit and dispose() on unmount. Co-Authored-By: Claude Sonnet 5 --- resources/js/components/MonacoEditor.test.ts | 103 +++++++++++++++---- resources/js/components/MonacoEditor.vue | 43 ++++++-- 2 files changed, 121 insertions(+), 25 deletions(-) diff --git a/resources/js/components/MonacoEditor.test.ts b/resources/js/components/MonacoEditor.test.ts index 3b48961..d89e5bc 100644 --- a/resources/js/components/MonacoEditor.test.ts +++ b/resources/js/components/MonacoEditor.test.ts @@ -20,14 +20,19 @@ vi.mock('@/lib/monacoEditorWorker', () => ({ createEditorWorker: vi.fn(), })); -const languageServerHandle = { +const intelephenseHandle = { + dispose: vi.fn(), + notifyContentChanged: vi.fn(), +}; +const laravelLspHandle = { dispose: vi.fn(), notifyContentChanged: vi.fn(), }; // attachLanguageServer has its own test (languageServer.test.ts) proving the LSP handshake // and provider behavior; replaced here so this test only proves MonacoEditor.vue calls it -// with the right arguments and reacts correctly to its resolved handle. +// with the right arguments and reacts correctly to its resolved handle. Resolves per call +// based on the config's ownerKey, since MonacoEditor.vue now calls it once per server. vi.mock('@/lib/languageServer', () => ({ attachLanguageServer: vi.fn(), })); @@ -65,14 +70,20 @@ beforeEach(() => { editor.getValue.mockClear(); editor.layout.mockClear(); onDidChangeModelContent.mockClear(); - languageServerHandle.dispose.mockClear(); - languageServerHandle.notifyContentChanged.mockClear(); + intelephenseHandle.dispose.mockClear(); + intelephenseHandle.notifyContentChanged.mockClear(); + laravelLspHandle.dispose.mockClear(); + laravelLspHandle.notifyContentChanged.mockClear(); vi.mocked(monaco.editor.create).mockClear(); vi.mocked(monaco.editor.defineTheme).mockClear(); vi.mocked(monaco.editor.setTheme).mockClear(); vi.mocked(attachLanguageServer) .mockReset() - .mockResolvedValue(languageServerHandle); + .mockImplementation(async (_monaco, config) => + config.ownerKey === 'laravel-lsp' + ? laravelLspHandle + : intelephenseHandle, + ); }); function actionRun(id: string): () => void { @@ -174,7 +185,15 @@ it('emits run when the Ctrl/Cmd+Enter action runs', () => { ); }); -it('attaches the language server for the current project', () => { +async function attachedHandles(): Promise { + await Promise.all( + vi + .mocked(attachLanguageServer) + .mock.results.map((result) => result.value.catch(() => undefined)), + ); +} + +it('attaches intelephense for the current project', () => { render(MonacoEditor, { props: { initialValue: ' { ); }); -it('forwards content changes to the language server once attached', async () => { +it('attaches laravel-lsp for the current project', () => { render(MonacoEditor, { props: { initialValue: ' { + render(MonacoEditor, { + props: { initialValue: ' { +it('disposes both language servers once attached and the editor unmounts', async () => { const rendered = render(MonacoEditor, { props: { initialValue: ' { +it('disposes a language server immediately if it resolves after the editor already unmounted', async () => { const rendered = render(MonacoEditor, { props: { initialValue: ' { +it('keeps the editor usable when both language servers fail to attach', async () => { vi.mocked(attachLanguageServer) .mockReset() .mockRejectedValue(new Error('boom')); @@ -234,10 +278,31 @@ it('keeps the editor usable when the language server fails to attach', async () props: { initialValue: ' undefined); + await attachedHandles(); onDidChangeModelContent.mock.calls[0]?.[0](); expect(rendered.emitted().change).toEqual([[' { + vi.mocked(attachLanguageServer) + .mockReset() + .mockImplementation(async (_monaco, config) => { + if (config.ownerKey === 'laravel-lsp') { + throw new Error('laravel-lsp unavailable'); + } + + return intelephenseHandle; + }); + + render(MonacoEditor, { + props: { initialValue: ' { const value = editor?.getValue() ?? ''; emit('change', value); - languageServer?.notifyContentChanged(value); + intelephenseServer?.notifyContentChanged(value); + laravelLspServer?.notifyContentChanged(value); }); editor.addAction({ id: 'tinkerbench.run', @@ -95,6 +98,9 @@ onMounted(() => { watch(theme, () => monaco.editor.setTheme(monacoThemeName())); + // Each language server attaches independently: one failing to start (e.g. a machine + // without laravel-lsp's binary in a broken vendor/ install) doesn't stop the other from + // attaching, and the editor itself remains fully usable even if both fail. attachLanguageServer( monaco, { @@ -111,17 +117,42 @@ onMounted(() => { return; } - languageServer = handle; + intelephenseServer = handle; + }) + .catch(() => undefined); + + attachLanguageServer( + monaco, + { + requestPortUrl: StartLaravelLanguageServerController.url( + props.project, + ), + ownerKey: 'laravel-lsp', + initializationOptions: { + phpEnvironment: 'herd', + pestGenerateDocBlocks: false, + }, + }, + props.initialValue, + editor.getModel()!, + ) + .then((handle) => { + if (unmounted) { + handle.dispose(); + + return; + } + + laravelLspServer = handle; }) - // The language server is optional: PHP autocompletion/hover/signature help stay off, - // but the editor itself remains fully usable without it. .catch(() => undefined); }); onBeforeUnmount(() => { unmounted = true; editor?.dispose(); - languageServer?.dispose(); + intelephenseServer?.dispose(); + laravelLspServer?.dispose(); }); From 11dd926a9f2f6e37aeb8c92300b97c15c1c2732d Mon Sep 17 00:00:00 2001 From: Maurice Hadamczyk Date: Mon, 24 Aug 2026 11:47:31 +0200 Subject: [PATCH 08/13] Prevent EPIPE from crashing a detached bridge process Process::options(['create_new_console' => true]) lets the bridge survive past the HTTP request, but the parent PHP Process object closes its read end of the bridge's own stdout/stderr pipes once garbage collected shortly after the request ends. From that point on, any write to those streams (e.g. createServerProcess()'s own stderr relay of the child language server's output) throws EPIPE as an uncaught exception, crashing the whole bridge - silently, since nothing was watching for it. Reproduced live: a real request against this app crashed the bridge mid-session as soon as the analyzed language server wrote enough of its own stderr. This affects both bridges equally (same shared core), though it manifests far more often for laravel-lsp, which does real project analysis and logs proportionally more than intelephense in a typical session. Co-Authored-By: Claude Sonnet 5 --- app/Support/bin/language-server-bridge.mjs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/Support/bin/language-server-bridge.mjs b/app/Support/bin/language-server-bridge.mjs index 8fa42cb..e754c82 100644 --- a/app/Support/bin/language-server-bridge.mjs +++ b/app/Support/bin/language-server-bridge.mjs @@ -6,6 +6,15 @@ import { toSocket } from 'vscode-ws-jsonrpc'; import { createServerProcess, createWebSocketConnection, forward } from 'vscode-ws-jsonrpc/server'; import { WebSocketServer } from 'ws'; +// The parent PHP process (Process::options(['create_new_console' => true])) stops reading this +// process's own stdout/stderr once it has the reported port, and closes its read end of those +// pipes once its Process object is garbage collected shortly after. From that point on, this is +// a detached process with nothing left reading its stdio at all, so a write to either (e.g. +// createServerProcess()'s own `${serverName} Server: ...` stderr relay, further down) hits a +// closed pipe (EPIPE) and would otherwise crash the whole bridge as an uncaught exception. +process.stdout.on('error', () => {}); +process.stderr.on('error', () => {}); + const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // Safari (macOS 15+) blocks a plain ws:// connection from an https:// page as mixed content, even to From 0214a5488ea57abe37de1ace43cb96496f36fbd2 Mon Sep 17 00:00:00 2001 From: Maurice Hadamczyk Date: Mon, 24 Aug 2026 11:48:06 +0200 Subject: [PATCH 09/13] Resolve the target project's PHP explicitly for laravel-lsp laravel-lsp's own phpEnvironment: 'herd' auto-detection (`herd which-php`) fails the same way its own shebang did: the nested spawn chain (PHP web request -> this app's bridge -> php laravel-lsp) never gives that inner `herd` invocation a PATH with `herd` on it either, so it silently falls back to an unqualified `php`, which then can't run `artisan tinker` against the target project at all. Every completion request returned a PHP-runner error instead of real results - even though the bridge itself was healthy. Resolves the target project's own PHP via Herd::phpBinary($project) - the same method already used to resolve intelephense's - and passes it as an explicit phpCommand in initializationOptions, bypassing laravel-lsp's own broken auto-detection entirely. Adds a regression test that does a real completion round trip against a real project and asserts actual items come back, not just that the connection stays open - the exact gap that let this reach production undetected. Verified fixed against a live Herd project in a real browser: route()/config() completions and diagnostics now work. Co-Authored-By: Claude Sonnet 5 --- .../StartLaravelLanguageServerAction.php | 8 ++- app/Support/LaravelLspBridge.php | 3 +- app/Support/bin/laravel-lsp-bridge.mjs | 21 +++++--- .../StartLaravelLanguageServerActionTest.php | 3 +- tests/Unit/Support/LaravelLspBridgeTest.php | 51 +++++++++++++++++-- 5 files changed, 72 insertions(+), 14 deletions(-) diff --git a/app/Actions/StartLaravelLanguageServerAction.php b/app/Actions/StartLaravelLanguageServerAction.php index 58d6df0..3e34358 100644 --- a/app/Actions/StartLaravelLanguageServerAction.php +++ b/app/Actions/StartLaravelLanguageServerAction.php @@ -25,6 +25,12 @@ public function execute(string $project): int // binary is required. $phpBinary = $this->herd->phpBinary($this->herd->currentProject()); - return $this->bridge->start($projectPath, $phpBinary); + // Separately, laravel-lsp needs the *target* project's own PHP to run artisan commands + // against it (e.g. resolving real config values) - resolved explicitly here rather than + // left to laravel-lsp's own `herd which-php` auto-detection, which fails the same way + // under this nested spawn chain. + $targetPhpBinary = $this->herd->phpBinary($project); + + return $this->bridge->start($projectPath, $phpBinary, $targetPhpBinary); } } diff --git a/app/Support/LaravelLspBridge.php b/app/Support/LaravelLspBridge.php index fc53135..1136311 100644 --- a/app/Support/LaravelLspBridge.php +++ b/app/Support/LaravelLspBridge.php @@ -11,7 +11,7 @@ class LaravelLspBridge { private const int START_TIMEOUT_SECONDS = 60; - public function start(string $projectPath, string $phpBinary): int + public function start(string $projectPath, string $phpBinary, string $targetPhpBinary): int { // This request blocks waiting on the bridge process below, bounded by Process's own // timeout, so PHP's own max_execution_time is lifted past that same bound, with headroom @@ -26,6 +26,7 @@ public function start(string $projectPath, string $phpBinary): int base_path('app/Support/bin/laravel-lsp-bridge.mjs'), $projectPath, $phpBinary, + $targetPhpBinary, ]); $port = null; diff --git a/app/Support/bin/laravel-lsp-bridge.mjs b/app/Support/bin/laravel-lsp-bridge.mjs index fce9fe0..0a2dbc2 100644 --- a/app/Support/bin/laravel-lsp-bridge.mjs +++ b/app/Support/bin/laravel-lsp-bridge.mjs @@ -5,10 +5,10 @@ import { runBridge } from './language-server-bridge.mjs'; const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); const LARAVEL_LSP_SCRIPT = path.join(PROJECT_ROOT, 'vendor/bin/laravel-lsp'); -const [, , projectPath, phpBinary] = process.argv; +const [, , projectPath, phpBinary, targetPhpBinary] = process.argv; -if (!projectPath || !phpBinary) { - console.error('Usage: laravel-lsp-bridge.mjs '); +if (!projectPath || !phpBinary || !targetPhpBinary) { + console.error('Usage: laravel-lsp-bridge.mjs '); process.exit(1); } @@ -17,9 +17,18 @@ function rewriteToTargetProject(message) { message.params.rootUri = `file://${projectPath}`; message.params.rootPath = projectPath; message.params.workspaceFolders = [{ uri: `file://${projectPath}`, name: 'project' }]; - // pestGenerateDocBlocks defaults to true and writes storage/framework/testing/_pest.php into the - // target project on disk, a side effect this bridge shouldn't cause just by attaching. - message.params.initializationOptions = { phpEnvironment: 'herd', pestGenerateDocBlocks: false }; + message.params.initializationOptions = { + // Bypasses laravel-lsp's own `herd which-php` auto-detection (phpEnvironment: 'herd'), + // which fails under this app's nested spawn chain (PHP web request -> this bridge -> + // php laravel-lsp): that inner `herd` invocation doesn't inherit a PATH with `herd` on + // it either, silently falling back to an unqualified `php` that then can't run + // `artisan tinker` against the target project at all. An already-resolved, explicit + // binary sidesteps that resolution entirely. + phpCommand: [targetPhpBinary], + // pestGenerateDocBlocks defaults to true and writes storage/framework/testing/_pest.php into the + // target project on disk, a side effect this bridge shouldn't cause just by attaching. + pestGenerateDocBlocks: false, + }; } return message; diff --git a/tests/Unit/Actions/StartLaravelLanguageServerActionTest.php b/tests/Unit/Actions/StartLaravelLanguageServerActionTest.php index 0963d59..5c16703 100644 --- a/tests/Unit/Actions/StartLaravelLanguageServerActionTest.php +++ b/tests/Unit/Actions/StartLaravelLanguageServerActionTest.php @@ -12,9 +12,10 @@ $mock->shouldReceive('projectPath')->once()->with('other-project')->andReturn('/path/to/other-project'); $mock->shouldReceive('currentProject')->once()->andReturn('tinkerbench'); $mock->shouldReceive('phpBinary')->once()->with('tinkerbench')->andReturn('/path/to/tinkerbench/php'); + $mock->shouldReceive('phpBinary')->once()->with('other-project')->andReturn('/path/to/other-project/php'); }); $this->mock(LaravelLspBridge::class, function (MockInterface $mock): void { - $mock->shouldReceive('start')->once()->with('/path/to/other-project', '/path/to/tinkerbench/php')->andReturn(54213); + $mock->shouldReceive('start')->once()->with('/path/to/other-project', '/path/to/tinkerbench/php', '/path/to/other-project/php')->andReturn(54213); }); $port = resolve(StartLaravelLanguageServerAction::class)->execute('other-project'); diff --git a/tests/Unit/Support/LaravelLspBridgeTest.php b/tests/Unit/Support/LaravelLspBridgeTest.php index 277217a..b173f50 100644 --- a/tests/Unit/Support/LaravelLspBridgeTest.php +++ b/tests/Unit/Support/LaravelLspBridgeTest.php @@ -6,7 +6,7 @@ use Illuminate\Support\Facades\Process; it('spawns a detached bridge process that survives past the request and reports its port', function (): void { - $port = new LaravelLspBridge()->start(sys_get_temp_dir(), PHP_BINARY); + $port = new LaravelLspBridge()->start(sys_get_temp_dir(), PHP_BINARY, PHP_BINARY); expect($port)->toBeGreaterThan(0); @@ -25,7 +25,7 @@ }); it('rejects a websocket handshake from another origin', function (): void { - $port = new LaravelLspBridge()->start(sys_get_temp_dir(), PHP_BINARY); + $port = new LaravelLspBridge()->start(sys_get_temp_dir(), PHP_BINARY, PHP_BINARY); $connected = Process::run([ config('services.herd.nvm_exec'), @@ -43,7 +43,7 @@ // laravel-lsp validates that its rootUri (rewritten from $projectPath by the bridge script, // regardless of what the client sends) is a real Laravel project, so this needs an actual // one - this project's own root, portable across machines and CI. - $port = new LaravelLspBridge()->start(base_path(), PHP_BINARY); + $port = new LaravelLspBridge()->start(base_path(), PHP_BINARY, PHP_BINARY); // Proves the spawned laravel-lsp process itself is reachable end-to-end, not just that the // wrapping bridge's own WebSocket server accepts a connection: a WS handshake succeeding @@ -63,12 +63,53 @@ expect($result->output())->toContain('"serverInfo"'); }); +it('resolves real config completions for the target project, not just a stub response', function (): void { + // Regression test: laravel-lsp's own `phpEnvironment: herd` auto-detection (`herd which-php`) + // fails silently under this app's own nested spawn chain (PHP web request -> bridge -> php + // laravel-lsp), falling back to an unqualified `php` that isn't on that chain's PATH either - + // laravel-lsp then can't run `artisan tinker` against the target project at all, and every + // completion request returns a PHP-runner error instead of real results. Passing an explicit, + // already-resolved $targetPhpBinary as `phpCommand` sidesteps that broken auto-detection + // entirely. A bare "did the socket open" test can't catch this - it needs a real completion + // round trip against a real project. + $port = new LaravelLspBridge()->start(base_path(), PHP_BINARY, PHP_BINARY); + + $result = Process::run([ + config('services.herd.nvm_exec'), + 'node', + '-e', + "const ws = new (require('ws'))('wss://tinkerbench.test:{$port}', { rejectUnauthorized: false, headers: { Origin: 'https://tinkerbench.test' } }); ". + 'let id = 1; const pending = new Map(); '. + "function send(m) { ws.send(JSON.stringify({ jsonrpc: '2.0', ...m })); } ". + 'function request(method, params) { const rid = id++; return new Promise((r) => { pending.set(rid, r); send({ id: rid, method, params }); }); } '. + 'function notify(method, params) { send({ method, params }); } '. + "ws.on('message', (data) => { const m = JSON.parse(data.toString()); if (m.id !== undefined && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } }); ". + "ws.on('open', async () => { ". + ' await request("initialize", { processId: null, rootUri: null, capabilities: {} }); '. + ' notify("initialized", {}); '. + " const uri = 'file:///tinkerbench-snippet.php'; ". + ' notify("textDocument/didOpen", { textDocument: { uri, languageId: "php", version: 1, text: " setTimeout(r, 3000)); '. + ' const completion = await request("textDocument/completion", { textDocument: { uri }, position: { line: 2, character: 13 } }); '. + ' const items = Array.isArray(completion.result) ? completion.result : (completion.result?.items ?? []); '. + ' process.stdout.write(JSON.stringify({ count: items.length, error: completion.error?.message ?? null })); '. + ' process.exit(0); '. + '}); '. + "ws.on('error', (error) => { process.stderr.write(String(error)); process.exitCode = 1; }); ". + 'setTimeout(() => { process.stderr.write("timed out"); process.exit(1); }, 15000);', + ]); + + $decoded = json_decode($result->output(), true); + + expect($decoded)->not->toBeNull()->and($decoded['error'])->toBeNull()->and($decoded['count'])->toBeGreaterThan(0); +}); + it('throws when the herd Node runtime is not configured', function (): void { config(['services.herd.nvm_exec' => null]); - new LaravelLspBridge()->start(sys_get_temp_dir(), PHP_BINARY); + new LaravelLspBridge()->start(sys_get_temp_dir(), PHP_BINARY, PHP_BINARY); })->throws(InvalidArgumentException::class); it('throws when the bridge script does not report a port', function (): void { - new LaravelLspBridge()->start('', PHP_BINARY); + new LaravelLspBridge()->start('', PHP_BINARY, PHP_BINARY); })->throws(InvalidArgumentException::class); From 0a94f6e5c63739537f00fd766c151384aecee5aa Mon Sep 17 00:00:00 2001 From: Maurice Hadamczyk Date: Mon, 24 Aug 2026 11:59:39 +0200 Subject: [PATCH 10/13] Use each server's own declared completion trigger characters triggerCharacters was hardcoded to intelephense's needs ($, >, :), which meant typing "." or a quote never re-queried the server for a narrower completion list - e.g. config('app. kept showing the first, top-level batch of keys fetched right after the opening quote instead of asking again for app.*, since Monaco only re-invokes a completion provider on its own declared trigger characters. Reads completionProvider.triggerCharacters from each server's own initialize response instead, the same thing a real LSP client (VS Code included) already does - this is what makes the two servers behave correctly side by side, since intelephense and laravel-lsp trigger on different characters for good reason. Co-Authored-By: Claude Sonnet 5 --- resources/js/lib/languageServer.test.ts | 56 ++++++++++++++++++++++++- resources/js/lib/languageServer.ts | 19 +++++++-- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/resources/js/lib/languageServer.test.ts b/resources/js/lib/languageServer.test.ts index 8c653be..1c9757d 100644 --- a/resources/js/lib/languageServer.test.ts +++ b/resources/js/lib/languageServer.test.ts @@ -115,14 +115,20 @@ beforeEach(() => { afterEach(() => vi.unstubAllGlobals()); -async function connectAndHandshake(): Promise { +async function connectAndHandshake( + initializeResult: unknown = {}, +): Promise { await vi.waitFor(() => expect(sockets).toHaveLength(1)); const socket = sockets[0]!; socket.open(); await vi.waitFor(() => expect(socket.sent).toHaveLength(1)); const initialize = JSON.parse(socket.sent[0]!) as { id: number }; - socket.receive({ jsonrpc: '2.0', id: initialize.id, result: {} }); + socket.receive({ + jsonrpc: '2.0', + id: initialize.id, + result: initializeResult, + }); return socket; } @@ -815,6 +821,52 @@ it('clears markers when disposed', async () => { ); }); +it("registers the completion provider with the server's own declared trigger characters", async () => { + const attaching = attachLanguageServer( + monaco, + intelephenseConfig, + ' { + const attaching = attachLanguageServer( + monaco, + intelephenseConfig, + ' { const attaching = attachLanguageServer( monaco, diff --git a/resources/js/lib/languageServer.ts b/resources/js/lib/languageServer.ts index f41eb49..162eea8 100644 --- a/resources/js/lib/languageServer.ts +++ b/resources/js/lib/languageServer.ts @@ -314,7 +314,7 @@ export async function attachLanguageServer( // intelephense behaves the same way here as it does in VS Code: snippet-formatted completions (parameter // placeholders), resolve() for auto-import edits it otherwise omits from the bulk list, plaintext docs // (no markdown support wired up here, so asking for markdown would just leak raw ** and ` syntax). - await request('initialize', { + const initializeResult = (await request('initialize', { processId: null, rootUri: null, initializationOptions: config.initializationOptions, @@ -342,7 +342,11 @@ export async function attachLanguageServer( publishDiagnostics: {}, }, }, - }); + })) as { + capabilities?: { + completionProvider?: { triggerCharacters?: string[] }; + }; + } | null; notify('initialized', {}); notify('textDocument/didOpen', { textDocument: { @@ -359,7 +363,16 @@ export async function attachLanguageServer( const completionProvider = monaco.languages.registerCompletionItemProvider( 'php', { - triggerCharacters: ['$', '>', ':'], + // Using the server's own declared trigger characters (rather than a value hardcoded + // for one server) matters once two servers share this document: intelephense and + // laravel-lsp trigger on different characters (e.g. laravel-lsp on the quote and the + // "." that separate config('app.name' into narrower and narrower keys), and Monaco + // only re-queries providers when the typed character is in this list - anything typed + // outside a provider's own list just keeps client-side-filtering an increasingly stale + // response instead of asking that provider again. + triggerCharacters: + initializeResult?.capabilities?.completionProvider + ?.triggerCharacters ?? [], async provideCompletionItems(model, position) { const result = (await request('textDocument/completion', { textDocument: { uri: DOCUMENT_URI }, From 02910e2b4284a99fe6a077a0b09505f41d0c1a17 Mon Sep 17 00:00:00 2001 From: Maurice Hadamczyk Date: Mon, 24 Aug 2026 12:05:44 +0200 Subject: [PATCH 11/13] Render completion and signature help docs as markdown Both servers send markdown-formatted documentation (code spans, links) regardless of the plaintext-only documentationFormat this client declared - confirmed directly against laravel-lsp, which returns e.g. a config value's resolved value plus a link to its source file as markdown. Hover already rendered this correctly since Monaco's Hover.contents is always markdown by construction, but CompletionItem.documentation and SignatureInformation.documentation are `string | IMarkdownString`: passing a bare string (what toPlainText() returns) renders it as literal text, so this markdown was showing up as raw, unrendered syntax in completion popups and signature help instead of formatted the way VS Code shows it. Also declares markdown support in capabilities.textDocument (all three: completion, hover, signatureHelp) to match what's actually handled now, rather than a preference neither server honors anyway. Co-Authored-By: Claude Sonnet 5 --- resources/js/lib/languageServer.test.ts | 8 +++--- resources/js/lib/languageServer.ts | 35 +++++++++++++++++-------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/resources/js/lib/languageServer.test.ts b/resources/js/lib/languageServer.test.ts index 1c9757d..bd2bfbe 100644 --- a/resources/js/lib/languageServer.test.ts +++ b/resources/js/lib/languageServer.test.ts @@ -481,9 +481,11 @@ it('resolves a completion item with the server-provided documentation and import const resolved = (await resolving) as { additionalTextEdits: unknown[]; - documentation: string; + documentation: { value: string }; }; - expect(resolved.documentation).toBe('Returns the length of a string.'); + expect(resolved.documentation).toEqual({ + value: 'Returns the length of a string.', + }); expect(resolved.additionalTextEdits).toEqual([ { text: 'use App\\Str;\n', @@ -626,7 +628,7 @@ it('returns the active signature and parameter reported by the language server', signatures: [ { label: 'strlen(string $string): int', - documentation: 'Returns the length of a string.', + documentation: { value: 'Returns the length of a string.' }, parameters: [{ label: 'string $string' }], }, ], diff --git a/resources/js/lib/languageServer.ts b/resources/js/lib/languageServer.ts index 162eea8..2e5a0f4 100644 --- a/resources/js/lib/languageServer.ts +++ b/resources/js/lib/languageServer.ts @@ -130,6 +130,21 @@ function toPlainText( return content.value; } +// Both LSP servers send markdown-formatted documentation (backtick code spans, links) regardless +// of the plaintext-only documentationFormat this client declares in its capabilities - unlike +// Hover.contents, which Monaco always renders as markdown by construction, CompletionItem and +// SignatureInformation's `documentation` field is `string | IMarkdownString`: a bare string +// renders as literal, unrendered text, so the markdown syntax those servers send would otherwise +// show up as-is (backticks, brackets) instead of being rendered. +function toMarkdown( + content: + LspMarkupContent | LspMarkupContent[] | string | string[] | undefined, +): { value: string } | undefined { + const text = toPlainText(content); + + return text === '' ? undefined : { value: text }; +} + function toAdditionalTextEdits( edits: LspTextEdit[] | undefined, ): { range: MonacoRange; text: string }[] | undefined { @@ -312,8 +327,9 @@ export async function attachLanguageServer( // Declares roughly what a real editor's LSP client (e.g. vscode-languageclient) already declares, so // intelephense behaves the same way here as it does in VS Code: snippet-formatted completions (parameter - // placeholders), resolve() for auto-import edits it otherwise omits from the bulk list, plaintext docs - // (no markdown support wired up here, so asking for markdown would just leak raw ** and ` syntax). + // placeholders), resolve() for auto-import edits it otherwise omits from the bulk list, and markdown docs + // (toMarkdown() renders them as such - both servers send markdown regardless of what's declared here, so + // this declares what's actually supported rather than what would just be a preference either server honors). const initializeResult = (await request('initialize', { processId: null, rootUri: null, @@ -323,7 +339,7 @@ export async function attachLanguageServer( completion: { completionItem: { snippetSupport: true, - documentationFormat: ['plaintext'], + documentationFormat: ['markdown', 'plaintext'], resolveSupport: { properties: [ 'documentation', @@ -333,10 +349,10 @@ export async function attachLanguageServer( }, }, }, - hover: { contentFormat: ['plaintext'] }, + hover: { contentFormat: ['markdown', 'plaintext'] }, signatureHelp: { signatureInformation: { - documentationFormat: ['plaintext'], + documentationFormat: ['markdown', 'plaintext'], }, }, publishDiagnostics: {}, @@ -402,8 +418,7 @@ export async function attachLanguageServer( kind: completionKindByLspKind[item.kind ?? 0] ?? monaco.languages.CompletionItemKind.Text, - documentation: - toPlainText(item.documentation) || undefined, + documentation: toMarkdown(item.documentation), insertText: item.textEdit?.newText ?? item.insertText ?? @@ -452,7 +467,7 @@ export async function attachLanguageServer( return { ...item, documentation: - toPlainText(resolved.documentation) || + toMarkdown(resolved.documentation) ?? item.documentation, // additionalTextEdits is how intelephense inserts the matching `use` statement when you // accept a completion for a class that isn't imported yet, same as it does in VS Code. @@ -509,9 +524,7 @@ export async function attachLanguageServer( activeParameter: result.activeParameter ?? 0, signatures: result.signatures.map((signature) => ({ label: signature.label, - documentation: - toPlainText(signature.documentation) || - undefined, + documentation: toMarkdown(signature.documentation), parameters: signature.parameters ?? [], })), }, From 64bacaf5e9f746e52098a8f4ba8177eb6d92ecfc Mon Sep 17 00:00:00 2001 From: Maurice Hadamczyk Date: Mon, 24 Aug 2026 12:08:08 +0200 Subject: [PATCH 12/13] Use each server's own declared signature help trigger characters Same principle as the completion provider's trigger characters: signatureHelpTriggerCharacters was hardcoded to ['(', ','], which happens to already match intelephense's own declaration, but laravel-lsp doesn't declare signatureHelpProvider at all (it has no signature help support). Reading it from the server's own initialize response instead means laravel-lsp's connection no longer registers trigger characters for a feature it can't answer. Co-Authored-By: Claude Sonnet 5 --- resources/js/lib/languageServer.test.ts | 40 +++++++++++++++++++++++++ resources/js/lib/languageServer.ts | 8 ++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/resources/js/lib/languageServer.test.ts b/resources/js/lib/languageServer.test.ts index bd2bfbe..f53a9fb 100644 --- a/resources/js/lib/languageServer.test.ts +++ b/resources/js/lib/languageServer.test.ts @@ -869,6 +869,46 @@ it('registers the completion provider with no trigger characters when the server ); }); +it("registers the signature help provider with the server's own declared trigger characters", async () => { + const attaching = attachLanguageServer( + monaco, + intelephenseConfig, + ' { + // laravel-lsp doesn't declare signatureHelpProvider at all (it has no signature help + // support), so this must not fall back to a hardcoded default meant for intelephense. + const attaching = attachLanguageServer( + monaco, + intelephenseConfig, + ' { const attaching = attachLanguageServer( monaco, diff --git a/resources/js/lib/languageServer.ts b/resources/js/lib/languageServer.ts index 2e5a0f4..0b782d7 100644 --- a/resources/js/lib/languageServer.ts +++ b/resources/js/lib/languageServer.ts @@ -361,6 +361,7 @@ export async function attachLanguageServer( })) as { capabilities?: { completionProvider?: { triggerCharacters?: string[] }; + signatureHelpProvider?: { triggerCharacters?: string[] }; }; } | null; notify('initialized', {}); @@ -503,7 +504,12 @@ export async function attachLanguageServer( const signatureHelpProvider = monaco.languages.registerSignatureHelpProvider('php', { - signatureHelpTriggerCharacters: ['(', ','], + // Same reasoning as the completion provider's triggerCharacters above: laravel-lsp + // doesn't declare signatureHelpProvider at all (no signature help support), so this + // ends up empty for it rather than wastefully triggering a request it can't answer. + signatureHelpTriggerCharacters: + initializeResult?.capabilities?.signatureHelpProvider + ?.triggerCharacters ?? [], async provideSignatureHelp(model, position) { const result = (await request('textDocument/signatureHelp', { textDocument: { uri: DOCUMENT_URI }, From 8b16d3d8d2169c8154ba9e82e45917a02c28ac7c Mon Sep 17 00:00:00 2001 From: Maurice Hadamczyk Date: Mon, 24 Aug 2026 13:27:07 +0200 Subject: [PATCH 13/13] Extract the bridge process launcher into LanguageServerBridgeLauncher and drop the unused initializationOptions plumbing --- app/Support/LanguageServerBridge.php | 45 +------------- app/Support/LanguageServerBridgeLauncher.php | 58 +++++++++++++++++++ app/Support/LaravelLspBridge.php | 46 +-------------- resources/js/components/MonacoEditor.test.ts | 4 -- resources/js/components/MonacoEditor.vue | 4 -- resources/js/lib/languageServer.test.ts | 28 --------- resources/js/lib/languageServer.ts | 2 - .../LanguageServerBridgeLauncherTest.php | 38 ++++++++++++ .../Unit/Support/LanguageServerBridgeTest.php | 15 +---- tests/Unit/Support/LaravelLspBridgeTest.php | 47 ++++----------- 10 files changed, 113 insertions(+), 174 deletions(-) create mode 100644 app/Support/LanguageServerBridgeLauncher.php create mode 100644 tests/Unit/Support/LanguageServerBridgeLauncherTest.php diff --git a/app/Support/LanguageServerBridge.php b/app/Support/LanguageServerBridge.php index 088551c..ed9703d 100644 --- a/app/Support/LanguageServerBridge.php +++ b/app/Support/LanguageServerBridge.php @@ -4,53 +4,12 @@ namespace App\Support; -use Illuminate\Support\Facades\Process; -use InvalidArgumentException; - class LanguageServerBridge { - private const int START_TIMEOUT_SECONDS = 60; + public function __construct(private LanguageServerBridgeLauncher $launcher) {} public function start(string $projectPath, string $phpVersion): int { - // This request blocks waiting on the bridge process below, bounded by Process's own - // timeout, so PHP's own max_execution_time is lifted past that same bound, with headroom - // for the process to actually be killed, otherwise the request would fatally time out - // from under a bridge that Process::timeout() is still waiting to terminate. Mirrors - // Herd::runSnippet()'s reasoning. - set_time_limit(self::START_TIMEOUT_SECONDS + 30); - - $invoked = Process::options(['create_new_console' => true])->timeout(self::START_TIMEOUT_SECONDS)->start([ - $this->nvmExec(), - 'node', - base_path('app/Support/bin/intelephense-bridge.mjs'), - $projectPath, - $phpVersion, - ]); - - $port = null; - - $invoked->waitUntil(function (string $type, string $line) use (&$port): bool { - if ($type !== 'out') { - return false; - } - - $port = (int) mb_trim($line); - - return true; - }); - - throw_unless(is_int($port) && $port >= 1 && $port <= 65535, InvalidArgumentException::class, 'The language server bridge did not report a valid port.'); - - return $port; - } - - private function nvmExec(): string - { - $path = config('services.herd.nvm_exec'); - - throw_if(! is_string($path) || $path === '', InvalidArgumentException::class, 'The services.herd.nvm_exec configuration must be a non-empty path.'); - - return $path; + return $this->launcher->start(base_path('app/Support/bin/intelephense-bridge.mjs'), [$projectPath, $phpVersion]); } } diff --git a/app/Support/LanguageServerBridgeLauncher.php b/app/Support/LanguageServerBridgeLauncher.php new file mode 100644 index 0000000..433c384 --- /dev/null +++ b/app/Support/LanguageServerBridgeLauncher.php @@ -0,0 +1,58 @@ + $args + */ + public function start(string $scriptPath, array $args): int + { + // This request blocks waiting on the bridge process below, bounded by Process's own + // timeout, so PHP's own max_execution_time is lifted past that same bound, with headroom + // for the process to actually be killed, otherwise the request would fatally time out + // from under a bridge that Process::timeout() is still waiting to terminate. Mirrors + // Herd::runSnippet()'s reasoning. + set_time_limit(self::START_TIMEOUT_SECONDS + 30); + + $invoked = Process::options(['create_new_console' => true])->timeout(self::START_TIMEOUT_SECONDS)->start([ + $this->nvmExec(), + 'node', + $scriptPath, + ...$args, + ]); + + $port = null; + + $invoked->waitUntil(function (string $type, string $line) use (&$port): bool { + if ($type !== 'out') { + return false; + } + + $port = (int) mb_trim($line); + + return true; + }); + + throw_unless(is_int($port) && $port >= 1 && $port <= 65535, InvalidArgumentException::class, 'The language server bridge did not report a valid port.'); + + return $port; + } + + private function nvmExec(): string + { + $path = config('services.herd.nvm_exec'); + + throw_if(! is_string($path) || $path === '', InvalidArgumentException::class, 'The services.herd.nvm_exec configuration must be a non-empty path.'); + + return $path; + } +} diff --git a/app/Support/LaravelLspBridge.php b/app/Support/LaravelLspBridge.php index 1136311..e6fe484 100644 --- a/app/Support/LaravelLspBridge.php +++ b/app/Support/LaravelLspBridge.php @@ -4,54 +4,12 @@ namespace App\Support; -use Illuminate\Support\Facades\Process; -use InvalidArgumentException; - class LaravelLspBridge { - private const int START_TIMEOUT_SECONDS = 60; + public function __construct(private LanguageServerBridgeLauncher $launcher) {} public function start(string $projectPath, string $phpBinary, string $targetPhpBinary): int { - // This request blocks waiting on the bridge process below, bounded by Process's own - // timeout, so PHP's own max_execution_time is lifted past that same bound, with headroom - // for the process to actually be killed, otherwise the request would fatally time out - // from under a bridge that Process::timeout() is still waiting to terminate. Mirrors - // Herd::runSnippet()'s reasoning. - set_time_limit(self::START_TIMEOUT_SECONDS + 30); - - $invoked = Process::options(['create_new_console' => true])->timeout(self::START_TIMEOUT_SECONDS)->start([ - $this->nvmExec(), - 'node', - base_path('app/Support/bin/laravel-lsp-bridge.mjs'), - $projectPath, - $phpBinary, - $targetPhpBinary, - ]); - - $port = null; - - $invoked->waitUntil(function (string $type, string $line) use (&$port): bool { - if ($type !== 'out') { - return false; - } - - $port = (int) mb_trim($line); - - return true; - }); - - throw_unless(is_int($port) && $port >= 1 && $port <= 65535, InvalidArgumentException::class, 'The language server bridge did not report a valid port.'); - - return $port; - } - - private function nvmExec(): string - { - $path = config('services.herd.nvm_exec'); - - throw_if(! is_string($path) || $path === '', InvalidArgumentException::class, 'The services.herd.nvm_exec configuration must be a non-empty path.'); - - return $path; + return $this->launcher->start(base_path('app/Support/bin/laravel-lsp-bridge.mjs'), [$projectPath, $phpBinary, $targetPhpBinary]); } } diff --git a/resources/js/components/MonacoEditor.test.ts b/resources/js/components/MonacoEditor.test.ts index d89e5bc..47fd93d 100644 --- a/resources/js/components/MonacoEditor.test.ts +++ b/resources/js/components/MonacoEditor.test.ts @@ -219,10 +219,6 @@ it('attaches laravel-lsp for the current project', () => { { requestPortUrl: '/api/projects/my-project/laravel-language-server', ownerKey: 'laravel-lsp', - initializationOptions: { - phpEnvironment: 'herd', - pestGenerateDocBlocks: false, - }, }, ' { props.project, ), ownerKey: 'laravel-lsp', - initializationOptions: { - phpEnvironment: 'herd', - pestGenerateDocBlocks: false, - }, }, props.initialValue, editor.getModel()!, diff --git a/resources/js/lib/languageServer.test.ts b/resources/js/lib/languageServer.test.ts index f53a9fb..0ffca70 100644 --- a/resources/js/lib/languageServer.test.ts +++ b/resources/js/lib/languageServer.test.ts @@ -955,31 +955,3 @@ it('requests a port from the configured URL and applies diagnostics under the co ], ); }); - -it('merges the configured initializationOptions into the initialize request', async () => { - const attaching = attachLanguageServer( - monaco, - { - requestPortUrl: - '/api/projects/customer-portal/laravel-language-server', - ownerKey: 'laravel-lsp', - initializationOptions: { - phpEnvironment: 'herd', - pestGenerateDocBlocks: false, - }, - }, - '; ownerKey: string; requestPortUrl: string; } @@ -333,7 +332,6 @@ export async function attachLanguageServer( const initializeResult = (await request('initialize', { processId: null, rootUri: null, - initializationOptions: config.initializationOptions, capabilities: { textDocument: { completion: { diff --git a/tests/Unit/Support/LanguageServerBridgeLauncherTest.php b/tests/Unit/Support/LanguageServerBridgeLauncherTest.php new file mode 100644 index 0000000..2b6fec3 --- /dev/null +++ b/tests/Unit/Support/LanguageServerBridgeLauncherTest.php @@ -0,0 +1,38 @@ +start( + base_path('app/Support/bin/intelephense-bridge.mjs'), + [sys_get_temp_dir(), '8.5'], + ); + + expect($port)->toBeGreaterThan(0)->and($port)->toBeLessThanOrEqual(65535); +}); + +it('ignores output on other streams while waiting for the port line on stdout', function (): void { + // '-e' as the "script path" runs this inline instead of a file, the same way `node -e` would + // on a command line - the delay guarantees the stderr write is polled on its own before the + // stdout write, rather than risking both arriving in the same poll. + $port = new LanguageServerBridgeLauncher()->start('-e', [ + "process.stderr.write('a warning printed before the port is announced'); setTimeout(() => process.stdout.write('54213'), 100);", + ]); + + expect($port)->toBe(54213); +}); + +it('throws when the herd Node runtime is not configured', function (): void { + config(['services.herd.nvm_exec' => null]); + + new LanguageServerBridgeLauncher()->start( + base_path('app/Support/bin/intelephense-bridge.mjs'), + [sys_get_temp_dir(), '8.5'], + ); +})->throws(InvalidArgumentException::class); + +it('throws when the script does not report a port', function (): void { + new LanguageServerBridgeLauncher()->start('', []); +})->throws(InvalidArgumentException::class); diff --git a/tests/Unit/Support/LanguageServerBridgeTest.php b/tests/Unit/Support/LanguageServerBridgeTest.php index a0cdd69..57cea77 100644 --- a/tests/Unit/Support/LanguageServerBridgeTest.php +++ b/tests/Unit/Support/LanguageServerBridgeTest.php @@ -3,10 +3,11 @@ declare(strict_types=1); use App\Support\LanguageServerBridge; +use App\Support\LanguageServerBridgeLauncher; use Illuminate\Support\Facades\Process; it('spawns a detached bridge process that survives past the request and reports its port', function (): void { - $port = new LanguageServerBridge()->start(sys_get_temp_dir(), '8.5'); + $port = new LanguageServerBridge(new LanguageServerBridgeLauncher())->start(sys_get_temp_dir(), '8.5'); expect($port)->toBeGreaterThan(0); @@ -25,7 +26,7 @@ }); it('rejects a websocket handshake from another origin', function (): void { - $port = new LanguageServerBridge()->start(sys_get_temp_dir(), '8.5'); + $port = new LanguageServerBridge(new LanguageServerBridgeLauncher())->start(sys_get_temp_dir(), '8.5'); $connected = Process::run([ config('services.herd.nvm_exec'), @@ -38,13 +39,3 @@ expect($connected->output())->toContain('rejected'); }); - -it('throws when the herd Node runtime is not configured', function (): void { - config(['services.herd.nvm_exec' => null]); - - new LanguageServerBridge()->start(sys_get_temp_dir(), '8.5'); -})->throws(InvalidArgumentException::class); - -it('throws when the bridge script does not report a port', function (): void { - new LanguageServerBridge()->start('', ''); -})->throws(InvalidArgumentException::class); diff --git a/tests/Unit/Support/LaravelLspBridgeTest.php b/tests/Unit/Support/LaravelLspBridgeTest.php index b173f50..6d3c7ad 100644 --- a/tests/Unit/Support/LaravelLspBridgeTest.php +++ b/tests/Unit/Support/LaravelLspBridgeTest.php @@ -2,11 +2,12 @@ declare(strict_types=1); +use App\Support\LanguageServerBridgeLauncher; use App\Support\LaravelLspBridge; use Illuminate\Support\Facades\Process; it('spawns a detached bridge process that survives past the request and reports its port', function (): void { - $port = new LaravelLspBridge()->start(sys_get_temp_dir(), PHP_BINARY, PHP_BINARY); + $port = new LaravelLspBridge(new LanguageServerBridgeLauncher())->start(sys_get_temp_dir(), PHP_BINARY, PHP_BINARY); expect($port)->toBeGreaterThan(0); @@ -25,7 +26,7 @@ }); it('rejects a websocket handshake from another origin', function (): void { - $port = new LaravelLspBridge()->start(sys_get_temp_dir(), PHP_BINARY, PHP_BINARY); + $port = new LaravelLspBridge(new LanguageServerBridgeLauncher())->start(sys_get_temp_dir(), PHP_BINARY, PHP_BINARY); $connected = Process::run([ config('services.herd.nvm_exec'), @@ -39,30 +40,6 @@ expect($connected->output())->toContain('rejected'); }); -it('responds to a real initialize request through the bridged connection', function (): void { - // laravel-lsp validates that its rootUri (rewritten from $projectPath by the bridge script, - // regardless of what the client sends) is a real Laravel project, so this needs an actual - // one - this project's own root, portable across machines and CI. - $port = new LaravelLspBridge()->start(base_path(), PHP_BINARY, PHP_BINARY); - - // Proves the spawned laravel-lsp process itself is reachable end-to-end, not just that the - // wrapping bridge's own WebSocket server accepts a connection: a WS handshake succeeding - // (the other tests here) says nothing about whether the child process behind it is alive, - // since that process is only spawned once a client actually connects. - $result = Process::run([ - config('services.herd.nvm_exec'), - 'node', - '-e', - "const ws = new (require('ws'))('wss://tinkerbench.test:{$port}', { rejectUnauthorized: false, headers: { Origin: 'https://tinkerbench.test' } }); ". - "ws.on('open', () => ws.send(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { processId: null, rootUri: null, capabilities: {} } }))); ". - "ws.on('message', (data) => { process.stdout.write(data.toString()); ws.close(); }); ". - "ws.on('error', (error) => { process.stderr.write(String(error)); process.exitCode = 1; }); ". - 'setTimeout(() => { process.stderr.write("timed out"); process.exit(1); }, 15000);', - ]); - - expect($result->output())->toContain('"serverInfo"'); -}); - it('resolves real config completions for the target project, not just a stub response', function (): void { // Regression test: laravel-lsp's own `phpEnvironment: herd` auto-detection (`herd which-php`) // fails silently under this app's own nested spawn chain (PHP web request -> bridge -> php @@ -72,7 +49,13 @@ // already-resolved $targetPhpBinary as `phpCommand` sidesteps that broken auto-detection // entirely. A bare "did the socket open" test can't catch this - it needs a real completion // round trip against a real project. - $port = new LaravelLspBridge()->start(base_path(), PHP_BINARY, PHP_BINARY); + // + // Also the only place proving the process actually responds to the LSP protocol at all: the + // initialize/initialized/didOpen handshake below has to succeed before a completion can come + // back, so a broken or unreachable laravel-lsp process fails here (timeout or empty count) + // exactly as it would in a dedicated bare-handshake test - this project's own root is real + // enough for both, portable across machines and CI. + $port = new LaravelLspBridge(new LanguageServerBridgeLauncher())->start(base_path(), PHP_BINARY, PHP_BINARY); $result = Process::run([ config('services.herd.nvm_exec'), @@ -103,13 +86,3 @@ expect($decoded)->not->toBeNull()->and($decoded['error'])->toBeNull()->and($decoded['count'])->toBeGreaterThan(0); }); - -it('throws when the herd Node runtime is not configured', function (): void { - config(['services.herd.nvm_exec' => null]); - - new LaravelLspBridge()->start(sys_get_temp_dir(), PHP_BINARY, PHP_BINARY); -})->throws(InvalidArgumentException::class); - -it('throws when the bridge script does not report a port', function (): void { - new LaravelLspBridge()->start('', PHP_BINARY, PHP_BINARY); -})->throws(InvalidArgumentException::class);