Add Laravel-aware completions and diagnostics to the snippet editor - #13
Merged
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… and drop the unused initializationOptions plumbing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The snippet editor already understood generic PHP through intelephense, but had no notion of
Laravel's own vocabulary. Writing
config('app.naorroute('users.show'in a snippet got nocompletion for the actual keys or route names defined in the target project, and a typo'd route
or config key wasn't caught until the snippet ran and failed.
What changed
The editor now also runs Laravel's own language server against the real target project,
alongside the existing one.
route(),config(), andenv()calls get completions and hoverinformation pulled from that project's actual routes, config, and environment; Eloquent model
completions can reflect real database columns. A reference to a route or config key that doesn't
exist is flagged before the snippet ever runs. Completion trigger characters and documentation
formatting now come from what each language server itself declares rather than being hardcoded
for the original server alone, so typing a dot or quote inside a call narrows suggestions
correctly and formatted documentation renders instead of showing raw markdown syntax.
Verification
background process that runs the language server, the new endpoint that starts it, and
completion/diagnostic behavior with both language servers attached together.
config()androute()completions returnreal project data, an unknown route is flagged, and no test-helper file is written into the
target project as a side effect of attaching.
language server couldn't find a PHP interpreter when launched from a web request, a background
process could crash once nothing was left reading its output, and the language server's own
PHP auto-detection failed for the same underlying reason as the first issue.
Risks
the background process and connection overhead per snippet opened.
messages sent between the browser and the local background processes.
Review focus
finishes: its output pipes get closed once nothing is reading them, so anything that still
writes to them afterward can crash the whole process (
app/Support/bin/language-server-bridge.mjs).wrong reintroduces failures that only appear when running under the real web server, not from
a terminal.
laravel/lsp) was added tocomposer.json.Documentation and visuals
Hover and completion text with formatting (code spans, links) now renders properly instead of
showing raw markdown syntax.