This is a reference for every feature from the overview mindmap, grouped by the same six themes:
- Navigate
- Edit
- Understand
- Validate
- Find
- Performance
Each section names the LSP wire method where applicable and describes the xphp
specific behaviour layered on top.
For forward-looking work (planned, exploratory), see
roadmap.
LSP method: textDocument/definition.
Resolves the symbol under the cursor to its declaration. Works on
classes, functions, methods, properties, use import aliases, and
PHP / phpstorm-stubs native symbols. Crucially, resolution flows
through xphp generics: if $users is declared as Collection<User>
and the cursor sits on $users->first(), the jump lands on the
correct User method, not on the template's placeholder T. Union
and intersection receivers fan out to a per-constituent picker so
each branch is reachable individually. The turbofish forms of the
self / static / parent pseudo-types (new self::<T>(),
self::method::<T>(...)) navigate and highlight like any other call
site.
LSP method: textDocument/typeDefinition.
Jumps to the class behind a variable's type, walking through generic
substitution. Cursor on $users declared as Collection<User> jumps
to class User rather than class Collection. Useful for verifying
that type-arg inference matches the developer's mental model.
LSP method: textDocument/references.
Project-wide reference search for classes, functions, methods, and properties. Two distinguishing behaviours:
- Subclass receivers are walked, so a search from
Base::m()finds call sites on instances typed againstDerived(or any further subclass). - Interface-implementation walks run in BOTH directions: cursor on
Iface::mmatches every implementor's call site; cursor onImpl::mmatches receivers typed against the interface.
LSP method: textDocument/implementation.
Lists every implementor of an interface or abstract method, plus subclass overrides. Complements Go to Definition (which lands on the declaration) by enumerating the concrete downstream sites.
LSP methods: textDocument/prepareCallHierarchy,
callHierarchy/incomingCalls, callHierarchy/outgoingCalls.
Bidirectional call graph for a selected method or function. V1 is intentionally lenient on receiver-type disambiguation (matches by name only), matching IntelliJ Java's behaviour for the same surface.
LSP methods: textDocument/prepareTypeHierarchy,
typeHierarchy/supertypes, typeHierarchy/subtypes.
Bidirectional supertype / subtype tree for any class or interface.
Walks both directions of the extends / implements graph from the
selected ClassLike.
LSP method: textDocument/documentSymbol.
Hierarchical outline of every ClassLike, function, and method declaration in the current file. Powers Cmd+O / Ctrl+F12 Structure popups in IDE editors.
LSP method: workspace/symbol.
Cross-file FQN search backed by an in-memory index built lazily on
first query and refreshed via workspace/didChangeWatchedFiles.
Powers Go to Class / Go to Symbol popups.
LSP method: textDocument/documentHighlight.
In-file occurrence highlighting. Placing the cursor on any symbol underlines every other use of that symbol within the same file.
LSP method: textDocument/rename.
Alias-aware short-name rewriting across the project. When a class is
aliased via use Foo\Bar as Baz;, the rename respects the alias
boundary at each reference site.
The PhpStorm plugin closes the PSR-4 loop end-to-end on top of the base LSP behaviour:
- Shift+F6 on a class renames the file to match the new class name.
- Renaming a file in the project tree updates the class declaration and every reference site.
- Cross-directory file moves also update the namespace declaration
and every consuming
useimport.
LSP method: workspace/willRenameFiles (LSP 3.17).
Pre-rename hook that returns text edits the editor applies before the rename commits. Used to keep class declarations and references in sync when the editor (not a user-triggered Shift+F6) initiates the file rename.
LSP methods: textDocument/codeAction, codeAction/resolve.
Quick fixes and refactorings, computed lazily via the resolve round-trip so cursor movement stays responsive. Currently offered:
- Import class -- when a bare short name resolves to a known FQN, offer one action per candidate.
- Simplify FQN -- shrinks
\App\Models\UsertoUserand adds the matchingusestatement. - Optimize Imports -- drops unused
uselines from the active file. - "Did you mean
null/true/false?" typo fixes attached toUndefinedNamediagnostics, using Levenshtein distance against the small set of constants frequently misspelled as a bareword. - Bound-violation fixes -- on a
Generic bound violateddiagnostic: "Change type argument to<Candidate>" (one per workspace type that satisfies the whole bound) and, for an intersection or single-leaf bound, "Add implements\Leafto<Concrete>" once per leaf the concrete class is missing. Union bounds offer only the swap (implementing any one leaf is ambiguous).
LSP methods: textDocument/codeLens, codeLens/resolve.
"Show references" lens above every class / interface / trait / enum
/ function / method declaration. The resolve step fills in a lazy
reference count; the lens carries a namespaced xphp.showReferences
command (with the locations baked in) that each client handles
client-side -- VS Code via a wrapper command that forwards to its
built-in references peek, the PhpStorm plugin via a usage chooser
anchored at the lens position rather than the caret.
The command is advertised in executeCommandProvider by default --
PhpStorm's LSP API only renders a CodeLens as clickable when its
command is advertised. VS Code instead auto-registers a forwarding
command for every advertised command (which would shadow its own
client-side handler), so the VS Code extension opts out via
initializationOptions: {advertiseCodeLensCommand: false} and the
server then omits it.
LSP method: textDocument/hover.
Quick documentation for whatever the cursor sits on, with xphp generics folded in. Beyond standard class / function / method / property / native function info, hover renders:
- Parameter and return-type substitution at static, instance, and free-function call sites.
- Generic
Tresolved to the concrete type, including through property fetches ($item = $box->itemwhere$box: Box<Tag>showsTag, notT). - A type parameter's full upper bound, including composite forms --
intersection (
A & B), union (A | B), and F-bounded (Comparable<T>). - A type parameter's variance:
out T(covariant) /in T(contravariant) are shown with their marker and a label; invariant params show the bare name. - A method-level type parameter, including one bounded by the
enclosing class param: hovering
Uinclass Box<out E> { function contains<U : E>(...) }showsType parameter U of App\Box::contains bounded by E. - A
Closure(int $x, string $y): boolsignature type in a parameter, return, or property position, rendered as its structured form (Closure(int, string): bool) -- params without names, nullable and composite (union / intersection) members preserved. - A generic method's own signature with its type-parameter clause
and
Closure(...)signature params restored -- hovering a call tomap<R>(Closure(E $x): R $fn): List<R>rendersmap<R>(Closure(E): R $fn): ...rather than the generics-erasedmap(Closure $fn)form. Inherited generic methods resolve against the declaring base class. - A local variable's type is resolved flow-sensitively: a variable reassigned to different generic types reads as the type in effect at the cursor, not the last assignment in the file.
LSP method: textDocument/signatureHelp.
Inline parameter list with the active argument highlighted. Type-arg
substitution is baked into the rendered signature: a call to
new Box::<Tag>(...) shows Tag rather than T in the parameter
hint. Works at static, instance, and free-function call sites.
LSP method: textDocument/inlayHint.
Inline substituted variable types after assignments. For example,
$user = $users->first() where $users is Collection<User>
renders the inferred type ?App\Models\User inline so the type
isn't hidden behind a hover.
LSP method: textDocument/foldingRange.
Collapsible regions for class / method / closure bodies plus xphp
<...> generic clauses (so the visual noise of a long type-arg
list can be folded away in deeply-nested generic call sites).
LSP method: textDocument/semanticTokens/full.
AST-driven syntax highlighting using the standard LSP token-type
legend. Type-parameter T references render with the
typeParameter color in generic-syntax positions, distinguishing
them visually from regular class references. This extends to generic
closures and arrows (fn<T>(…), function<T>(…)): the declaration
clause and body-level T references inside the closure are coloured
as type parameters.
Diagnostics surface in both push (textDocument/publishDiagnostics)
and pull (textDocument/diagnostic, LSP 3.17) modes. Seven diagnostic
codes are emitted today: xphp.parse, xphp.bound, xphp.definition
(duplicate template), xphp.undefined-name, xphp.ctor-arg-mismatch,
xphp.arg-mismatch, and xphp.closure_conformance.
Syntax errors detected by nikic/php-parser after the xphp <...>
clauses are stripped, with positional spans that map back to the
original source via the byte-offset map. Tolerant-parse recovery
means a single typo doesn't suppress every later diagnostic in the
file.
Compile-time validation of T: Bound against each concrete
type-arg. The hierarchy spans the whole project on disk (not just
open buffers), so new Box::<Tag>(...) resolves correctly even when
Tag.xphp isn't currently open in the editor. Error messages
reference the source-level instantiation (e.g. Box<int>) rather
than the hashed specialization name.
A factory whose declared return type is Closure(...) and that
hands back a closure literal provably violating that target --
a return type that isn't a subtype, a parameter type that isn't
wider (contravariance), a wrong arity, or a by-ref mismatch -- is
flagged with xphp.closure_conformance. The check itself is owned
by the xphp compiler; the server surfaces it. Return-position
literals are caught live as you type; closure literals passed as
call arguments to a generic method (e.g.
$box->map::<string>(fn(int $x) => ...)) are caught by the on-save
whole-project check below, because verifying them requires grounding
the receiver's type parameters against the actual specialization.
Diagnostics run in two tiers. The fast tier re-analyzes open buffers
on every keystroke (tolerant parse) and drives completion, hover, and
the live squiggles above. On save, a second authoritative tier
runs the xphp compiler's own whole-project validator
(Compiler::check()) over the manifest's source set -- the same
analysis xphp check performs -- and merges its findings in. This
tier sees the whole program at once, so it reaches diagnostics the
per-buffer tier structurally cannot: grounded, call-argument closure
conformance, and cross-file generic errors where the declaration and
the use site live in different files. It reads from disk, so it
reflects the last-saved state; editing a file supersedes its
authoritative findings until the next save. No compiler subprocess is
spawned -- the validator runs in-process.
The source set is discovered per saved file: the server walks up from
the file's own directory to the nearest xphp.json and scopes the check to
that manifest, so a file resolves to its own project even in a multi-root
or mis-rooted workspace (the server tracks a single legacy rootPath and
does not read workspaceFolders). A file with no ancestor manifest falls
back to the workspace root. Very large / unscoped source sets are skipped
(with a log line) so the synchronous check can never block the editor.
Not a diagnostic code of its own -- this is how the bound and
argument-type checks treat omitted defaults. A generic with trailing
defaults (class Box<T = \stdClass>, class Pair<A, B = A>) may be
instantiated with the defaulted args omitted (new Box::<>(),
new Pair::<Dog>(...)). The argument-type checker resolves the
effective type for each omitted slot left-to-right (so B = A picks up
the supplied A) and never reports a false "missing type argument",
while still substituting the effective type into method parameter
checks. (An empty turbofish on a template with a non-defaulted
parameter is still reported -- as xphp.bound -- since the
instantiation is genuinely incomplete.)
Fires when two files declare the same generic class / interface / trait template at the same FQN. Pins to the second declaration's file for actionability, since the first one is already in scope by the time the duplicate is parsed.
Catches references to identifiers (functions, constants) that
aren't declared anywhere reachable. Paired with the "Did you mean
null / true / false?" code action so the obvious typo cases
are fixable in one keystroke.
Post-monomorphization check on new C(...) and new C::<T>(...)
call sites. Catches the case where the supplied argument's
statically-known type can't satisfy the constructor parameter's
declared type -- a runtime TypeError waiting to happen, surfaced
at compile time. Inference is intentionally narrow (literals,
new ClassName(...), true / false / null const fetches) to
avoid false positives on arguments whose type would require flow
analysis to know.
The same narrow-inference check, extended beyond constructors to
method calls ($obj->m(...)), static calls (Cls::m(...)), and free
functions (freeFn(...)). Type-argument turbofish is honoured: an
instance-method turbofish ($obj->m::<T>(...)) binds its type
argument for the check. Cases that would require flow analysis are
conservatively skipped rather than guessed -- a variable turbofish
($f::<T>(...)) over an unknown callee, and an over-supplied
type-argument list (more args than the template declares), produce no
mismatch.
LSP method: textDocument/completion.
Context-aware completion in every meaningful position:
- Type-arg position (
new Box::<|>(...)) -- bound-aware filtering hides candidates that don't satisfy the slot's declared upper bound; scalars are dropped when the bound is class-like. Composite bounds are respected: a candidate must satisfy every leaf of an intersection (T : A & B) and any leaf of a union (T : A | B). - Closure-signature type position (
function h(Closure(|): ...)) -- a type token inside aClosure(int $x, string $y): boolsignature (first param, after a,, or the return type after:) offers class names and scalars, the same unbounded candidate set a bound-free type-arg slot gets. - Member access (
$obj->) and static access (Cls::) -- methods, properties, and constants from the receiver. - Static property access (
Cls::$) -- a distinct context kind so the$sigil round-trips correctly through accept. - Local variables -- scope-aware: function / method / closure / arrow-function bodies don't leak names from sibling scopes.
- Visibility filtering inside same-class and subclass contexts (private members only inside the declaring class; protected members visible across subclass receivers, etc.).
- Union / intersection receiver fan-out -- union shows the permissive union of members; intersection shows the conservative intersection.
- String / comment / docblock suppression -- the popup doesn't fire inside literal text, so typing inside a string doesn't trigger a member-access menu.
The insertText for class-name candidates is scope-aware: bare
short name when the FQN is already imported or same-namespace,
aliased short name for use Foo as Bar;, leading-backslash
\FQN otherwise. Never inserts the qualified-but-not-FQ form
that would namespace-prepend at PHP name-resolution time.
LSP method: completionItem/resolve.
Docblock fetch deferred until the user navigates to a specific item. Keeps the popup responsive on cold start; full documentation fills in as items receive focus, not for every candidate up front.
Implementation properties that determine how the server behaves under load, on cold start, and across editor sessions. Not LSP methods in their own right, but visible to users through editor responsiveness and reliability.
When the workspace declares an xphp 0.3.0 xphp.json manifest, the
FQN index resolves its source roots (auto-detected by walking up from
the workspace root, or via an explicit config path) and walks them
alongside the workspace root -- so declarations in a source root that
lives outside the editor's root still resolve for go-to-definition,
hover, and completion. The manifest's build-output and
generated-class-cache directories are pruned from the walk, so the
specialized PHP the compiler emits is never indexed as source (and
never shadows the .xphp original). A file reachable through more
than one root is indexed once. An absent or malformed manifest falls
back to the single workspace root -- the server never hard-fails on a
bad manifest.
Opening a file from a sibling project outside the workspace root
extends this on the fly: on textDocument/didOpen, the server walks up
from the opened file to its own nearest xphp.json and folds that
project's source roots into the index. So with the workspace rooted at
one project, opening a file from a neighbouring package makes its
symbols resolve for go-to-definition, find-references, completion and
rename -- even though the server tracks only a single legacy rootPath
and does not read workspaceFolders. The newly-registered roots are
warmed off-thread so the first navigation into the sibling isn't cold.
Duplicate FQNs across projects blend by proximity (nearest declaration
to the working file), not hard per-project isolation.
On the LSP initialize handshake, a background warmer parses every
filesystem-indexed .xphp / .php file under the project root
into a version-keyed cache. Cold "Show references" on a 200-file
workspace drops from roughly seven and a half seconds to under 200
milliseconds -- subsequent walks skip the per-file parse entirely.
The same cache feeds the bound-check hierarchy and the
template-definition registry, so cross-file generic diagnostics
work without any dependency files being open.
worse-reflection's stub map (used to resolve PHP and
phpstorm-stubs native symbols) is serialised once per machine to a
cache directory resolved from $XPHP_LSP_CACHE_DIR -> XDG ->
~/.cache/xphp-lsp (Linux) -> ~/Library/Caches/xphp-lsp (macOS)
-> %LOCALAPPDATA%/xphp-lsp (Windows) -> <sys_temp>/xphp-lsp
fallback. Survives reboots and /tmp reaping, so cold-start cost
is paid once per machine, not once per session.
The stub map is also warmed off the initialize handshake: on a
cold machine, building it walks the entire phpstorm-stubs tree, so
paying it on the first hover would push that hover past the editor's
hover-cancel window and the popup would never paint. The warmer moves
the build to a background task at startup, so the first hover over a
variable whose type involves native functions (strlen, array_map,
…) responds promptly.
A single hover on a typed variable makes worse-reflection reflect the
same symbols many times over -- both the receiver class (repeatedly)
and every native symbol on the value's right-hand side. Those
reflections are memoized per session so the repeats within one hover,
and across the rapid repeat hovers an editor issues as the cursor
settles, collapse to one reflection each -- the difference between a
multi-second hover and one that lands inside the hover-cancel window.
The cache is keyed by symbol name, so it is flushed on every
didChange: an edit to an open buffer is always re-reflected on the
next hover, never served stale. Flushes land between user actions, so
they never undo the intra-hover memoization.
In-memory locators recover from trailing parse errors so mid-edit
source ($x->|, new Foo::<|) still returns useful completion /
hover / GTD results. Without this fallback, every incomplete
keystroke would temporarily break the editor's intelligence and
force the developer to wait for the source to be syntactically
valid again.
LSP positions are spec'd in UTF-16 code units, but PHP's native
string operations work in bytes. The server's PositionMap
translates between the two so positions stay accurate past
supplementary-plane codepoints (emoji and similar), avoiding the
off-by-N drift that would otherwise shift every position right of
the codepoint.
When the same short name (e.g. User) exists at multiple FQNs
across the project -- typically src/Models/User.xphp and
tests/Fixtures/User.xphp -- the resolver prefers the canonical
src/ path. Test fixture and vendor paths score a penalty so
navigation lands on the production declaration by default.
CI-friendly entry point that doesn't require an LSP client:
bin/xphp-lsp --lint path/to/file.xphp [more.xphp ...]Output format is <file>:<line>:<col>: <severity>: [<code>] <message>
-- the same shape PHPStan and php-cli emit, so editors and CI
greps consume it without ceremony. Exits non-zero if any file has
diagnostics, zero otherwise. Useful in PRs today as a fast
syntax-and-bound-check pass independent of the LSP transport.