diff --git a/app/Actions/StartLaravelLanguageServerAction.php b/app/Actions/StartLaravelLanguageServerAction.php new file mode 100644 index 0000000..3e34358 --- /dev/null +++ b/app/Actions/StartLaravelLanguageServerAction.php @@ -0,0 +1,36 @@ +herd->projectPath($project); + + throw_if($projectPath === null, RuntimeException::class, "Unknown Herd project: {$project}"); + + // 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()); + + // 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/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/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 new file mode 100644 index 0000000..e6fe484 --- /dev/null +++ b/app/Support/LaravelLspBridge.php @@ -0,0 +1,15 @@ +launcher->start(base_path('app/Support/bin/laravel-lsp-bridge.mjs'), [$projectPath, $phpBinary, $targetPhpBinary]); + } +} 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..e754c82 --- /dev/null +++ b/app/Support/bin/language-server-bridge.mjs @@ -0,0 +1,75 @@ +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'; + +// 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 +// 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), + ); + }); +} diff --git a/app/Support/bin/laravel-lsp-bridge.mjs b/app/Support/bin/laravel-lsp-bridge.mjs new file mode 100644 index 0000000..0a2dbc2 --- /dev/null +++ b/app/Support/bin/laravel-lsp-bridge.mjs @@ -0,0 +1,42 @@ +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_SCRIPT = path.join(PROJECT_ROOT, 'vendor/bin/laravel-lsp'); + +const [, , projectPath, phpBinary, targetPhpBinary] = process.argv; + +if (!projectPath || !phpBinary || !targetPhpBinary) { + 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' }]; + 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; +} + +runBridge({ + serverName: 'laravel-lsp', + spawnBin: phpBinary, + spawnArgs: [LARAVEL_LSP_SCRIPT], + rewriteMessage: rewriteToTargetProject, +}); 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", diff --git a/resources/js/components/MonacoEditor.test.ts b/resources/js/components/MonacoEditor.test.ts index 951494e..47fd93d 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,55 +185,87 @@ 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: ' { render(MonacoEditor, { props: { initialValue: ' { +it('forwards content changes to both language servers once attached', async () => { 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')); @@ -231,10 +274,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: ' import * as monaco from 'monaco-editor'; import { onBeforeUnmount, onMounted, useTemplateRef, watch } from 'vue'; +import StartLanguageServerController from '@/actions/App/Http/Controllers/StartLanguageServerController'; +import StartLaravelLanguageServerController from '@/actions/App/Http/Controllers/StartLaravelLanguageServerController'; import { useTheme } from '@/composables/useTheme'; import { attachLanguageServer } from '@/lib/languageServer'; import type { LanguageServerHandle } from '@/lib/languageServer'; @@ -15,7 +17,8 @@ const emit = defineEmits<{ const editorElement = useTemplateRef('editorElement'); const { theme } = useTheme(); let editor: monaco.editor.IStandaloneCodeEditor | null = null; -let languageServer: LanguageServerHandle | null = null; +let intelephenseServer: LanguageServerHandle | null = null; +let laravelLspServer: LanguageServerHandle | null = null; let unmounted = false; function monacoThemeName(): string { @@ -82,7 +85,8 @@ onMounted(() => { const value = editor?.getValue() ?? ''; emit('change', value); - languageServer?.notifyContentChanged(value); + intelephenseServer?.notifyContentChanged(value); + laravelLspServer?.notifyContentChanged(value); }); editor.addAction({ id: 'tinkerbench.run', @@ -94,9 +98,37 @@ 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, - props.project, + { + requestPortUrl: StartLanguageServerController.url(props.project), + ownerKey: 'intelephense', + }, + props.initialValue, + editor.getModel()!, + ) + .then((handle) => { + if (unmounted) { + handle.dispose(); + + return; + } + + intelephenseServer = handle; + }) + .catch(() => undefined); + + attachLanguageServer( + monaco, + { + requestPortUrl: StartLaravelLanguageServerController.url( + props.project, + ), + ownerKey: 'laravel-lsp', + }, props.initialValue, editor.getModel()!, ) @@ -107,17 +139,16 @@ onMounted(() => { return; } - languageServer = handle; + 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(); }); diff --git a/resources/js/lib/languageServer.test.ts b/resources/js/lib/languageServer.test.ts index fa49eb5..0ffca70 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(), @@ -110,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; } @@ -125,7 +136,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("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, + 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, + { + requestPortUrl: + '/api/projects/customer-portal/laravel-language-server', + ownerKey: 'laravel-lsp', + }, + ' { - const response = await fetch(StartLanguageServerController.url(project), { +async function requestPort(requestPortUrl: string): Promise { + const response = await fetch(requestPortUrl, { method: 'POST', headers: xsrfHeader(), }); @@ -125,6 +129,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 { @@ -157,11 +176,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 +278,7 @@ export async function attachLanguageServer( monaco.editor.setModelMarkers( model, - 'intelephense', + config.ownerKey, diagnostics.map((diagnostic) => ({ ...toMonacoRange(diagnostic.range, { lineNumber: 1, @@ -307,9 +326,10 @@ 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). - await request('initialize', { + // 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, capabilities: { @@ -317,7 +337,7 @@ export async function attachLanguageServer( completion: { completionItem: { snippetSupport: true, - documentationFormat: ['plaintext'], + documentationFormat: ['markdown', 'plaintext'], resolveSupport: { properties: [ 'documentation', @@ -327,16 +347,21 @@ export async function attachLanguageServer( }, }, }, - hover: { contentFormat: ['plaintext'] }, + hover: { contentFormat: ['markdown', 'plaintext'] }, signatureHelp: { signatureInformation: { - documentationFormat: ['plaintext'], + documentationFormat: ['markdown', 'plaintext'], }, }, publishDiagnostics: {}, }, }, - }); + })) as { + capabilities?: { + completionProvider?: { triggerCharacters?: string[] }; + signatureHelpProvider?: { triggerCharacters?: string[] }; + }; + } | null; notify('initialized', {}); notify('textDocument/didOpen', { textDocument: { @@ -353,7 +378,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 }, @@ -383,8 +417,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 ?? @@ -433,7 +466,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. @@ -469,7 +502,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 }, @@ -490,9 +528,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 ?? [], })), }, @@ -505,7 +541,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.'), ); 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..5c16703 --- /dev/null +++ b/tests/Unit/Actions/StartLaravelLanguageServerActionTest.php @@ -0,0 +1,32 @@ +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'); + $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', '/path/to/other-project/php')->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); 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 new file mode 100644 index 0000000..6d3c7ad --- /dev/null +++ b/tests/Unit/Support/LaravelLspBridgeTest.php @@ -0,0 +1,88 @@ +start(sys_get_temp_dir(), PHP_BINARY, PHP_BINARY); + + 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(new LanguageServerBridgeLauncher())->start(sys_get_temp_dir(), PHP_BINARY, PHP_BINARY); + + $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('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. + // + // 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'), + '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); +});