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.
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/). 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.
F22. Merge a namespace onto one that shares a class name¶
Impact: Low-Medium · Complexity: Medium-High
Renaming App\Internal to an App\Support that already exists now
merges: the files move into the existing directory one at a time
instead of the source directory being renamed on top of the
destination. Where the two namespaces declare the same class name
(App\Internal\Helper and App\Support\Helper both exist), the whole
rename is refused with a message naming the clash, because the merge
has no well-defined answer for that name — see
namespace_merge_conflict in src/rename/namespace/layout.rs.
Refusing is the safe answer, not the desirable one: a user merging twenty files should not be blocked by one clash. What is missing is a partial merge that moves everything except the clashing names and leaves those behind intact. "Intact" is the hard part and the reason this is deferred rather than done:
- The clashing class's file must keep its own
namespacedeclaration, so that file's namespace-declaration edit has to be suppressed while its siblings' are kept. Today the namespace rename rewrites by string prefix over raw text with no per-class granularity. - Every reference to the clashing FQN (
use App\Internal\Helper;,\App\Internal\Helper, and unqualifiedHelperinside the old namespace) must be left pointing at the old name. Rewriting them is what makes a naive partial merge worse than a refusal: the reference silently resolves to the other class. - A file holding both a clashing class and a non-clashing one cannot half-move, so it has to be reported as a clash too.
- The classes that do move out of the old namespace stop being
namespace-siblings of the ones left behind, so the files left behind
need
useimports added for them — the same rulebuild_class_move_editalready applies for a single class move.
The refusal message should then list only the names that could not move, and the response should still carry the edits for everything that could.
Where to look: src/rename/namespace.rs
(namespace_merge_conflict, build_namespace_prefix_rename_edit,
collect_merge_move_ops), src/rename/class.rs (the import-adding
rule to mirror).
F23. Rename a class through its YAML/XML occurrences¶
Impact: Medium · Complexity: Medium
A fully-qualified class name in a YAML or XML file is found by Find
References and counted by the declaration CodeLens, but rename leaves
it alone: find_references_inner drops resource locations in
ReferenceSearchMode::Rename. Renaming the class therefore leaves the
config pointing at a name that no longer exists, and move reports the
leftover through its residual scan rather than fixing it.
The blocker is that the text at such an occurrence is not always the
PHP spelling of the name. A YAML double-quoted scalar writes
"App\\Handler\\Run", so the span covers doubled separators; an XML
attribute may carry entity references. Rename plans a single
replacement string per location and verifies each range spells a whole
PHP name token before emitting anything, so an escaped occurrence both
fails verification (dropping the entire rename, including its PHP
edits, which is what the current exclusion prevents) and would be
rewritten with single separators, corrupting the document's quoting.
What is needed is a per-occurrence replacement: the scanner already knows the raw text it normalised, so it can record how the name was escaped and let the rename re-escape the replacement the same way, with verification asking for "a name token in this document's escaping" rather than a bare PHP one.
Where to look: src/resource_navigation.rs (scan_symbols,
normalize_fqn), src/references/dispatch.rs
(find_references_inner), src/rename/validate.rs
(Expected, is_name_token), src/rename/class.rs
(build_class_move_edit).