PHPantom — LSP Features¶
Items are ordered by impact (descending), then complexity (ascending) within the same impact tier.
| Label | Scale |
|---|---|
| Impact | Critical, High, Medium-High, Medium, Low-Medium, Low |
| Complexity | Low (mechanical/boilerplate, no design decisions), Medium (self-contained, follows an existing pattern), Medium-High (spans modules, some new design), High (shared/core subsystem, correctness or performance tradeoffs), Very High (cross-cutting architecture, wide blast radius) |
F2. Partial result streaming via $/progress¶
Impact: Medium · Complexity: Medium-High
The LSP spec (3.17) allows requests that return arrays — such as
textDocument/implementation, textDocument/references,
workspace/symbol, and even textDocument/completion — to stream
incremental batches of results via $/progress notifications when both
sides negotiate a partialResultToken. The final RPC response then
carries null (all items were already sent through progress).
This would let PHPantom deliver the first useful results almost instantly instead of blocking until every source has been scanned.
Streaming between existing phases¶
find_implementors already runs five sequential phases (see
docs/ARCHITECTURE.md § Go-to-Implementation):
- Phase 1 — uri_classes_index (already-parsed classes in memory) — essentially free. Flush results immediately.
- Phase 2 — fqn_uri_index (FQN → URI entries not yet in uri_classes_index) — loads individual files. Flush after each batch.
- Phase 3 — classmap files (Composer classmap, user + vendor mixed) — iterates unique file paths, applies string pre-filter, parses matches. This is the widest phase and the best candidate for within-phase streaming (see below).
- Phase 4 — embedded stubs (string pre-filter → lazy parse) — flush after stubs are checked.
- Phase 5 — PSR-4 directory walk (user code only, catches files not in the classmap) — disk I/O + parse per file, good candidate for per-file streaming.
Each phase boundary is a natural point to flush a $/progress batch,
so the editor starts populating the results list while heavier phases
are still running.
Prioritising user code within Phase 3¶
Phase 3 iterates the Composer classmap, which contains both user and
vendor entries. Currently they are processed in arbitrary order. A
simple optimisation: partition classmap file paths into user paths
(under PSR-4 roots from composer.json autoload / autoload-dev)
and vendor paths (everything else, typically under vendor/), then
process user paths first. This way the results most relevant to the
developer arrive before vendor matches, even within a single phase.
Granularity options¶
- Per-phase batches (simplest) — one
$/progressnotification at each of the five phase boundaries listed above. - Per-file streaming — within Phases 3 and 5, emit results as each file is parsed from disk instead of waiting for the entire phase to finish. Phase 3 can iterate hundreds of classmap files and Phase 5 recursively walks PSR-4 directories, so per-file flushing would significantly improve perceived latency for large projects.
- Adaptive batching — collect results for a short window (e.g. 50 ms) then flush, balancing notification overhead against latency.
Applicable requests¶
| Request | Benefit |
|---|---|
textDocument/implementation |
Already scans five phases; each phase's matches can be streamed |
textDocument/references |
Will need full-project scanning; streaming is essential |
workspace/symbol |
Searches every known class/function; early batches feel instant |
textDocument/completion |
Less critical (usually fast), but long chains through vendor code could benefit |
Implementation sketch¶
- Check whether the client sent a
partialResultTokenin the request params. - If yes, create a
$/progresssender. After each scan phase (or per-file, depending on granularity), send aProgressParams { token, value: [items...] }notification. - Return
nullas the final response. - If no token was provided, fall back to the current behaviour: collect everything, return once.
F5. Call hierarchy¶
Impact: Medium · Complexity: Medium
Implement callHierarchy/incomingCalls and
callHierarchy/outgoingCalls to answer "who calls this function?" and
"what does this function call?"
Incoming calls (who calls this)¶
Given a function or method, find all call sites across the project. This is conceptually similar to Find References but filtered to call expressions and structured as a tree (each caller is itself a callable with a location).
The existing Find References infrastructure
(find_references_in_file, cross-file scanning) provides the core
search. The call hierarchy handler wraps the results into
CallHierarchyIncomingCall items, grouping by containing function.
Outgoing calls (what does this call)¶
Given a function or method, walk its AST body and collect all call
expressions (function calls, method calls, static calls, new
expressions). Resolve each callee to its declaration location.
This is a single-file AST walk with cross-file resolution for each callee, similar to what go-to-definition already does.
Prepare¶
callHierarchy/prepare returns a CallHierarchyItem for the symbol
at the cursor. This is straightforward: resolve the symbol, return its
name, kind, URI, range, and selection range.
Dependencies¶
Call hierarchy benefits significantly from a full project index. Without an index, incoming calls can only be found via the existing classmap + PSR-4 scan approach (same as Find References). Now that full background indexing is available, the lookup can become a simple index query instead of relying on the scan-based approach that Find References uses on its own.
References: - Phpactor: call hierarchy via its references index.
F7. Evaluatable expression support (DAP integration)¶
Impact: Low-Medium · Complexity: Low
Implement textDocument/evaluatableExpression so debuggers (Xdebug
via DAP) can evaluate expressions under the cursor during a debug
session. Given a cursor position, the handler returns the expression
text and range that the debugger should evaluate in the running PHP
process.
Supported expression kinds¶
- Variables:
$var— return the variable name and its span. - Property access:
$obj->prop,$this->prop— return the full member access expression. - Array access:
$arr[0],$arr['key']— return the full subscript expression including brackets. - Static property access:
Foo::$bar— return the full expression. - Parameters: function/method parameters at declaration sites.
Why this is cheap¶
The symbol map already identifies all of these constructs with precise
byte ranges. The handler is a thin layer: look up the SymbolSpan at
the cursor position, check that it's a variable, member access, or
subscript expression, and return the source text and range. No type
resolution needed.
What this enables¶
When a user is debugging PHP with Xdebug and hovers over $user->name
in their editor, the editor asks the LSP "what expression is here?"
and forwards it to the debug adapter for evaluation. Without this
handler, the editor falls back to selecting the word under the cursor,
which gives name instead of $user->name — useless for the
debugger.
F11. VS Code extension¶
| Field | Value |
|---|---|
| Impact | High |
| Complexity | Medium-High |
Create a VS Code extension that bundles PHPantom and publishes it to the VS Code Marketplace.
Approach¶
Fork the vscode-intelephense
client extension (MIT-licensed). Intelephense is the #1 PHP extension
in the VS Code Marketplace, so its package.json represents what
PHP developers expect from an extension: the settings schema,
activation events, file associations, categories, and contribution
points are battle-tested. Starting from this base means we do not
accidentally omit something users take for granted.
Strip the proprietary Intelephense server dependency (intelephense
npm package) and replace it with PHPantom binary management. The
extension is a thin TypeScript wrapper around vscode-languageclient
that spawns phpantom_lsp over stdio.
Cleanup process: After forking, compare the result against a
fresh VS Code extension scaffold (yo code generator) to identify
and remove Intelephense-specific legacy that does not apply to
PHPantom (licence key commands, telemetry integration, Node.js
runtime configuration, premium feature gating). The goal is a clean
extension that inherits the right UX expectations without carrying
over implementation baggage.
Scope¶
- Binary distribution. Bundle or auto-download the correct pre-built binary for each platform (linux-x64, linux-arm64, darwin-x64, darwin-arm64, win-x64). Use GitHub Releases as the download source.
- Settings surface. Expose PHPantom's
.phpantom.tomlsettings as VS Code settings (PHP version, diagnostics toggles, indexing strategy). - Status bar. Show indexing progress and server status.
- Marketplace listing. Icon, description, screenshots, categories, keywords.
- CI. GitHub Actions workflow to build, test, and publish the extension on release.
Code signing¶
macOS and Windows builds must be signed so the OS stops flagging PHPantom as malware. This is a prerequisite for the VS Code extension (users will not trust an extension that triggers Gatekeeper or SmartScreen warnings).
- macOS: Apple Developer ID certificate,
codesign, andnotarytoolin the release CI workflow. - Windows: Authenticode certificate (or Azure Trusted Signing)
and
signtoolin the release CI workflow.
F12. IntelliJ / PHPStorm plugin¶
| Field | Value |
|---|---|
| Impact | High |
| Complexity | Medium-High |
Create an IntelliJ plugin that depends on LSP4IJ and bundles PHPantom. Publish it to the JetBrains Marketplace. Works in all IntelliJ-based IDEs (PHPStorm, IntelliJ IDEA, WebStorm, etc.).
Approach¶
Fork clojure-lsp-intellij
(MIT-licensed). It is a Kotlin/Gradle plugin that registers a
language server via lsp4ij's com.redhat.devtools.lsp4ij.server
extension point. Strip the Clojure-specific parts and replace them
with PHPantom:
- Register PHPantom as the language server in
plugin.xml. - Map the
PHPlanguage and file type viacom.redhat.devtools.lsp4ij.languageMapping. - Bundle or auto-download the PHPantom binary.
- Add a settings page for the binary path and any PHPantom-specific options.
Scope¶
plugin.xmlregistration. Server definition, language mapping, file type mapping (.php,.phtml,.inc).- Binary management. Auto-download from GitHub Releases on first run, with a manual path override in settings.
- Settings UI. Binary path, PHP version override, diagnostic toggles.
- JetBrains Marketplace listing. Icon, description, plugin compatibility range (2024.2+, matching lsp4ij's requirement).
- CI. GitHub Actions workflow using
gradlew buildPluginandgradlew publishPlugin.
Why not use the built-in IntelliJ LSP API¶
IntelliJ's native LSP support (since 2023.2) is only available in Ultimate editions and is still limited in capability. LSP4IJ is free, works in all editions (including Community), and supports a broader set of LSP features. Using lsp4ij also means the plugin works in IntelliJ IDEA (for PHP projects opened there) and other JetBrains IDEs, not just PHPStorm.
F13. Homebrew formula¶
| Field | Value |
|---|---|
| Impact | Medium |
| Complexity | Low |
Create a Homebrew formula for PHPantom so users on macOS and Linux
can install it with brew install phpantom_lsp.
Approach¶
Submit a PR to homebrew-core
with a formula that downloads the pre-built binary from GitHub
Releases for the current platform. Alternatively, the formula can
build from source using cargo install if the Homebrew reviewers
prefer source builds (common for Rust projects).
Formula contents¶
- Homepage:
https://github.com/PHPantom-dev/phpantom_lsp - Source: GitHub Releases tarball or
cargo installfrom crates.io. - Binary:
phpantom_lsp - Test block:
system bin/"phpantom_lsp", "--version"
Why this matters¶
A Homebrew formula is a prerequisite for upstream PRs to editors like Helix, which prefer that language servers be installable via a package manager. It also simplifies the VS Code extension's binary management on macOS (detect Homebrew-installed binary before downloading).
F14. Helix upstream PR¶
| Field | Value |
|---|---|
| Impact | Low-Medium |
| Complexity | Low |
Depends on: F13 (Homebrew formula).
Submit a PR to the Helix editor
adding phpantom_lsp as a language server option in the default
languages.toml.
Change¶
Add a phpantom server definition and include it in the php
language entry (alongside intelephense):
[language-server.phpantom]
command = "phpantom_lsp"
# In the [[language]] entry for php, add "phpantom" to language-servers.
Prerequisites¶
- F13 (Homebrew formula) should be merged so Helix maintainers can
point users at
brew install phpantom_lsp. - Helix maintainers may want a brief README section documenting the server and its feature set.
F15. Go-to-declaration¶
Impact: Low-Medium · Complexity: Low
Implement textDocument/declaration to jump from a concrete method to
its abstract or interface prototype, complementing the existing
go-to-definition (which jumps to the concrete implementation) and
go-to-implementation (which jumps from an interface to concrete classes).
Behaviour¶
When the cursor is on a method call or method name:
- Search for an interface or abstract class that declares a method with the same name and is in the inheritance chain of the resolved class.
- If found, jump to the interface/abstract method declaration.
- If no abstract prototype exists, fall back to the same result as go-to-definition.
Implementation¶
The existing resolve_implementation already does reverse lookups
(concrete → prototype) via resolve_reverse_implementation. The
declaration handler can reuse this: for MemberAccess and
MemberDeclaration symbols, call the reverse-implementation resolver
first. For class-level symbols, declaration and definition are the
same.
Register declaration_provider in server.rs and wire it to a thin
handler that delegates to the existing infrastructure.
F16. On-type } brace de-indent¶
Impact: Low · Complexity: Low
Extend the existing on-type formatting handler (currently triggered on
\n for docblock generation) to also trigger on }, automatically
de-indenting the closing brace to match its opening {.
Behaviour¶
When the user types }:
- From the
}position, scan backward through the document text to find the matching{(tracking brace depth, skipping strings and comments). - Read the indentation of the line containing the matching
{. - If the
}line has more indentation than the{line, return aTextEditthat replaces the leading whitespace on the}line with the{line's indentation.
This is a pure text-based operation — no AST needed. Register } as
an additional on_type_formatting_trigger_character alongside the
existing \n.
F17. Wire class move to workspace/willRenameFiles¶
Impact: Medium · Complexity: Medium
Renaming a class's FQN via textDocument/rename already moves the file
and rewrites references across the project: renaming a class's
declaration accepts the full FQCN so it can move between namespaces in
one step, and renaming a namespace segment rewrites every affected
namespace declaration, use statement, and FQN reference while
moving the PSR-4 directories to match (see build_class_move_edit in
src/rename/class.rs and build_namespace_rename_edit in
src/rename/namespace.rs). What's still missing is the editor-triggered
path: when the user renames or moves a PHP file in the editor's file
tree (rather than through the LSP rename command), nothing updates the
file's namespace declaration or the workspace's use imports.
Wire the existing move logic to workspace/willRenameFiles (declared
via server capabilities workspace.fileOperations.willRename): on a
file-tree rename/move, recompute the namespace from the destination
path using the PSR-4 autoload map, and reuse the same reference-rewrite
machinery to produce the WorkspaceEdit. The companion
workspace/willCreateFiles can then insert a PSR-4-derived namespace
+ class stub into newly created files.
References:
- Phpactor: MoveClass refactoring in the class-mover package.
F19. Connect to a remote/TCP language server (VS Code extension)¶
Impact: Low · Complexity: Medium
This task is for the VS Code extension package, not the phpantom_lsp
server itself. The server can already speak LSP over a TCP socket; the
gap is purely on the client side, where the editor extensions only ever
spawn a local binary over stdio. Expose an option in the extension
(mirroring Phpactor's remote.enabled / remote.host / remote.port)
to connect to an already-running server instead of spawning one. This
covers running the server inside a container or on a remote host while
editing locally.
Scope¶
This is a client-side change in the editor extensions, not the server.
In the VS Code extension, add phpantom.remote.enabled, .host, and
.port settings; when enabled, build the language client from a socket
transport rather than a spawned process. Remote mode is a single shared
endpoint, so it bypasses the per-folder server model and uses one
client that matches all PHP documents (the same exception Phpactor's
extension makes).
Caveats¶
A remote server has its own filesystem view, so rootUri / workspace
paths must line up with the paths the server sees (or be remapped).
Auto-download, version checks, and the per-folder rooting do not apply
in remote mode.
F20. Migrate to the maintained tower-lsp fork¶
Impact: Low-Medium · Complexity: Very High
tower-lsp 0.20 (our current dependency) is the last release of the
original crate; it's unmaintained upstream. A maintained fork exists
as tower-lsp-server (types crate ls-types), actively developed as
of 2026. Because it's a rename rather than a version bump of the same
crate, cargo update/routine dependency audits will not surface this
on their own — nothing shows up as "outdated" since no new tower-lsp
version is being withheld. It has to be picked up as a deliberate
migration.
Does not unblock A16 or F21. Checked directly against ls-types
main and upstream lsp-types 0.97.0 source (not just docs): neither
crate implements SnippetTextEdit/StringValue (tracked upstream at
gluon-lang/lsp-types#310,
still open) or a static type_hierarchy_provider field on
ServerCapabilities (tracked at
gluon-lang/lsp-types#298
and tower-lsp-community/ls-types#38,
both open). ls-types also removed its generic "proposed" 3.18
feature flag in 0.0.4 ("only applied to a handful of v3.18 items"), so
there is no version bump or feature flag on our side that grants either
type today. Both are real 3.18-spec (@proposed) features, just not
yet implemented in any Rust LSP-types crate. The remaining motivation
for this migration is staying on an actively maintained crate — bug
and security fixes, and a path to 3.18 support once upstream catches
up — not unblocking a specific feature now. Re-check A16 and F21 for
upstream progress before assuming this migration alone resolves them.
The real complexity driver: ls-types's Uri is a newtype over
fluent_uri::Uri<String>, not url::Url. Our code uses Url
(re-exported from lsp_types) directly in roughly 90 files across
nearly every module — path manipulation, to_file_path/
from_file_path, .path(), .join(), and more — and fluent_uri's
API does not mirror url::Url's. This is a project-wide port of the
document-URI type, not a mechanical import rename. Scope it file by
file before committing to a single PR; it may need a preparatory
abstraction (e.g. isolate URI construction/parsing behind a narrow
internal helper) to keep the blast radius reviewable, and likely
warrants breaking into more than one PR despite the "one task per PR"
convention — raise that with the maintainer before starting.
What to also check: the fork's public API surface relative to
tower_lsp::LspService/tower_lsp::lsp_types (import paths, trait
signatures) to scope the mechanical rename across every file that does
use tower_lsp::... (grep tower_lsp:: for the full list:
src/lsp_dispatch.rs, src/inlay_hints.rs, src/document_symbols.rs,
src/folding.rs, src/phpcs.rs, src/fix.rs,
src/selection_range.rs, src/text_position.rs, and others), plus the
wire-protocol test harness described in test-porting.md Phase 6B if
that gets ported around the same time.
Where to look: Cargo.toml's tower-lsp = { version = "0.20", features = ["proposed"] }.
F21. Static typeHierarchyProvider advertisement (depends on F20)¶
Impact: Low-Medium · Complexity: Low
Type hierarchy (textDocument/prepareTypeHierarchy,
typeHierarchy/supertypes, typeHierarchy/subtypes) is fully
implemented (src/type_hierarchy.rs) and registered dynamically via
client/registerCapability in initialized (server.rs,
type_hierarchy_registration()), gated on the client declaring
textDocument.typeHierarchy.dynamicRegistration: true. This works for
every client that supports dynamic registration, but there is no
static fallback: lsp-types 0.94.1 (pinned by tower-lsp 0.20, see
F20) has no type_hierarchy_provider field on ServerCapabilities, so
a client that supports type hierarchy without dynamic registration —
or any tool that inspects only the initialize response's static
capabilities, such as a feature-conformance probe — sees no type
hierarchy support at all, even though the feature works end-to-end for
a real editor that does the dynamic-registration round trip.
The field is still missing after F20, not just before it: neither
upstream lsp-types nor the maintained tower-lsp-server/ls-types
fork that F20 migrates to has added type_hierarchy_provider yet
(tracked at
gluon-lang/lsp-types#298
and tower-lsp-community/ls-types#38,
both open) — so F20 landing is necessary but not sufficient here; this
also needs the upstream crate to add the field. Once both have
happened, add static advertisement (Boolean(true) or
TypeHierarchyOptions) in initialize's ServerCapabilities,
conditional on the client not declaring dynamicRegistration: true
for type hierarchy (avoid double-registering: send either the static
capability or the dynamic registration, not both, per the client's
declared support). Also
verify whether the same version bump exposes diagnostic client
capabilities more precisely — pull diagnostics (diagnostic_provider
in server.rs) is unaffected by this gap (it's already advertised
correctly whenever the client declares textDocument.diagnostic,
verified by probing initialize directly with that capability set),
but is worth a quick re-check after the migration in case the newer
lsp-types changes the shape of that capability struct.
Where to look: src/server.rs (initialize, type_hierarchy_registration),
src/type_hierarchy.rs.