Skip to content

Latest commit

 

History

History
546 lines (421 loc) · 22.4 KB

File metadata and controls

546 lines (421 loc) · 22.4 KB

Features

This is a reference for every feature from the overview mindmap, grouped by the same six themes:

  1. Navigate
  2. Edit
  3. Understand
  4. Validate
  5. Find
  6. 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.


Navigate

Go to Definition

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.

Go to Type Definition

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.

Find References

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 against Derived (or any further subclass).
  • Interface-implementation walks run in BOTH directions: cursor on Iface::m matches every implementor's call site; cursor on Impl::m matches receivers typed against the interface.

Find Implementations

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.

Call Hierarchy

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.

Type Hierarchy

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.

Document Symbol

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.

Workspace Symbol

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.

Document Highlight

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.


Edit

Rename

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 use import.

Workspace file rename

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.

Code Actions

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\User to User and adds the matching use statement.
  • Optimize Imports -- drops unused use lines from the active file.
  • "Did you mean null / true / false?" typo fixes attached to UndefinedName diagnostics, using Levenshtein distance against the small set of constants frequently misspelled as a bareword.
  • Bound-violation fixes -- on a Generic bound violated diagnostic: "Change type argument to <Candidate>" (one per workspace type that satisfies the whole bound) and, for an intersection or single-leaf bound, "Add implements \Leaf to <Concrete>" once per leaf the concrete class is missing. Union bounds offer only the swap (implementing any one leaf is ambiguous).

Code Lens

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.


Understand

Hover

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 T resolved to the concrete type, including through property fetches ($item = $box->item where $box: Box<Tag> shows Tag, not T).
  • 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 U in class Box<out E> { function contains<U : E>(...) } shows Type parameter U of App\Box::contains bounded by E.
  • A Closure(int $x, string $y): bool signature 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 to map<R>(Closure(E $x): R $fn): List<R> renders map<R>(Closure(E): R $fn): ... rather than the generics-erased map(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.

Signature Help

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.

Inlay Hints

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.

Folding Range

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).

Semantic Tokens

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.


Validate

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.

Parse errors

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.

Generic bound violations

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.

Closure signature conformance

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.

On-save whole-project check

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.

Default type arguments (no false missing-arg)

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.)

Duplicate template declarations

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.

Undefined bareword warnings

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.

Constructor argument-type mismatch (xphp.ctor-arg-mismatch)

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.

Argument-type mismatch (xphp.arg-mismatch)

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.


Find

Completion

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 a Closure(int $x, string $y): bool signature (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.

Lazy completion-item resolve

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.


Performance

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.

Project manifest (xphp.json) multi-root indexing

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.

AST cache (warmed on Initialize)

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.

Stub cache (durable, per-user)

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.

Reflection cache (per-session, edit-invalidated)

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.

Tolerant-parse fallback

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.

UTF-16 column counting

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.

Short-name tie-break

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.

Headless --lint mode

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.