Skip to content

LSP MVP: diagnostics, hover, definition, completion - #7

Merged
math3usmartins merged 17 commits into
mainfrom
feat/lsp
May 21, 2026
Merged

math3usmartins merged 17 commits into
mainfrom
feat/lsp

Conversation

@math3usmartins

@math3usmartins math3usmartins commented May 21, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds a language-server implementation at tools/lsp/ that surfaces xphp-aware
feedback live in any LSP client: real-time diagnostics (parse errors, bound
violations, duplicate-template declarations), hover (specialized FQN +
type-param bound), go-to-definition (instantiation -> template), and completion
of class names inside <...> type-arg positions. Plus a minimal VS Code client
under tools/lsp/vscode-extension/.

The server is in PHP, built on phpactor/language-server. The analyzer reuses
XphpSourceParser, Registry, TypeHierarchy, and RegistryCollector from
the core package verbatim -- zero edits to existing code, zero impact on
the 93 % MSI gate on src/. Cross-language feedback is achieved by wrapping
the existing throw-on-first-error pipeline in per-file try/catch and
converting each RuntimeException / PhpParser\Error into a framework-neutral
Diagnostic, then translating to the wire-format LSP Diagnostic at exactly
one boundary (DiagnosticTranslator).

Layout

tools/lsp/
|---- composer.json              path-references the parent xphp-parser
|---- bin/xphp-lsp               CLI entry -- `--lint <file>` for CI, no args for stdio LSP
|---- src/
|   |---- Server.php             entry-point router (--lint vs LSP transport)
|   |---- LspDispatcherFactory   phpactor middleware stack + DiagnosticsService wiring
|   |---- PositionMap.php        byte offset <-> {line, char} (UTF-8-aware)
|   |---- Analyzer/              per-file (Analyzer) + cross-file (WorkspaceAnalyzer)
|   |---- Diagnostics/           DiagnosticsProvider impl + wire-format translator
|   |---- Handler/               hover, definition, completion, position resolver, symbol cache
|   +---- Workspace/             DocumentStore (phpactor's Workspace handles open/close)
|---- infection.json5            93 % MSI gate, curated equivalent-mutation ignores
|---- phpunit.xml.dist
|---- test/                      121 PHPUnit cases, 276 assertions
+---- vscode-extension/          minimal VS Code client (TypeScript, vscode-languageclient/node)

Design decisions

These are the trade-offs worth disagreeing with. Documented inline at the
relevant call sites; restated here so reviewers can argue them in one place.

  1. PHP, not Node, for the server. Reusing the existing parser verbatim
    eliminated a re-implementation that would have been months of work for
    no functional gain. Node would have been the obvious choice if we
    needed to ship a wider ecosystem of editor integrations independently
    from the PHP toolchain, but the LSP wire format is the same regardless
    of language and the parser IS the contract.

  2. phpactor/language-server 6.2 for JSON-RPC scaffolding. It's the
    most mature PHP-native LSP framework, handles framing /
    document-sync / dispatch / capabilities / cancellation, and ships a
    DiagnosticsProvider extension point we plug straight into. Trade-offs
    pulled in:

    • psr/log ^1.0 transitive pin (blocks composer-installed Infection in
      this package -- see "Testing" below for the resolution).
    • thecodingmachine/safe PHP 8.4 implicit-nullable deprecation noise,
      suppressed at the bin entry-point (error_reporting(E_ALL & ~E_DEPRECATED))
      and via <source ignoreIndirectDeprecations="true"> in phpunit.xml.dist.
  3. Wrap, don't modify. The compiler throws on first error;
    the LSP needs per-document batched errors. Picked option B: the
    analyzer wraps each Registry call in try/catch and accumulates
    diagnostics. Option A (modifying Registry::recordInstantiation to
    optionally collect) would be cleaner long-term but touches well-tested
    code paths. Re-evaluate if a future feature needs structured errors
    from the compiler itself.

  4. Framework-neutral Diagnostic value type internally. The analyzer
    emits XPHP\Lsp\Analyzer\Diagnostic (plain readonly class). Handlers
    translate to Phpactor\LanguageServerProtocol\Diagnostic at the wire
    boundary only. Means the analyzer is testable without spinning up a
    transport, and a future LSP-framework swap touches only the
    DiagnosticTranslator.

  5. Re-parse per LSP request rather than cache ASTs. The analyzer is
    microseconds-fast; an AST cache keyed on document version would shave
    a small constant factor at the cost of cache-invalidation bugs.
    Profile-driven follow-up if it ever matters.

  6. Diagnostic publishing is per-changed-file, not workspace-broadcast.
    Editing Box.xphp re-publishes diagnostics for Box.xphp only. A
    Use.xphp that instantiates Box<int> won't re-render its bound
    violation until the next time the user touches it. The fix needs
    handler-level cross-file tracking; deferred to a follow-up alongside
    the disk Indexer (item 7).

  7. Open documents only. The handlers iterate phpactor\Workspace,
    which only knows about documents the editor has open. A workspace-
    wide Indexer walking **/*.xphp at initialize is the obvious
    next pass -- required for go-to-def + completion + cross-file
    diagnostics to work on files the user hasn't opened yet.

  8. PHAR-based Infection rather than composer-installed. Infection
    0.33 requires psr/log ^2 || ^3 which composer cannot reconcile with
    phpactor's ^1.0 pin; older Infection lines that allow psr/log ^1
    require symfony/console ^7 against the parent package's ^8.
    Running the root vendor's Infection against tools/lsp/ also fails
    at autoload time because both vendor trees install
    thecodingmachine/safe and the two copies redeclare the same global
    functions. Infection's PHAR ships its internal deps under
    PHP-Scoper-prefixed namespaces and side-steps both problems. The
    test/lsp/mutation Makefile target downloads the PHAR lazily into
    tools/lsp/var/ (gitignored). When phpactor finally bumps psr/log,
    migrate to a normal composer dev-dep.

Testing

Surface Tool Result
Unit + integration make test/lsp (PHPUnit) 121 tests, 276 assertions
Mutation make test/lsp/mutation (Infection PHAR, 0.33.1, --threads=max --min-covered-msi=93) 95 % Covered MSI (408 mutations, 387 killed, 20 escaped, 1 timeout, 100 % coverage)
Core regression make test/unit Unchanged: 171 tests, 453 assertions
Wire-format roundtrip LanguageServerTester over LspDispatcherFactory initialize -> capabilities ?
Live transport piped JSON-RPC through bin/xphp-lsp over stdio Verified for initialize, didOpen, hover, definition, completion

infection.json5 carries per-mutator ignore rules for genuinely
equivalent / defensive mutations -- every entry has an inline rationale.
Class-level ignores are scoped to specific methods, not whole files.
Mirrors the pattern at the repo root (/infection.json5).

The deprecation-suppression chain (error_reporting=E_ALL & ~E_DEPRECATED
at the bin entry-point, php -d error_reporting=... in the Makefile,
<source ignoreIndirectDeprecations="true"> in phpunit.xml.dist) is all
documented at each layer with the same cross-reference so future readers
don't re-discover the constraint chain.

How to try it locally

# Server
make test/lsp                # PHPUnit
make test/lsp/mutation       # Infection PHAR, downloads lazily on first run

# Headless lint (CI-friendly today, independent of the LSP transport)
tools/lsp/bin/xphp-lsp --lint path/to/file.xphp

# VS Code extension
make build/lsp-extension     # npm install + tsc
# then open tools/lsp/vscode-extension/ in VS Code and hit F5

The F5 Extension Development Host opens the repo root as the workspace,
so the playground *.xphp files under playground/src/Demos/ are
immediately usable as a test surface. Open
playground/src/Demos/Bounds.xphp, edit StringableBox<Tag> -> <int>,
see the red squiggle.

Out of scope (deferred follow-ups)

Each one is documented inline at the relevant source location too --
this list is the index, not the source of truth.

  • Workspace indexer: walk **/*.xphp at initialize so go-to-def and
    completion see unopened files.
  • Cross-file diagnostic broadcast: re-publish diagnostics for every
    document affected by an edit, not just the one being typed in.
  • Bound-aware completion filtering: Box<T: \Stringable> should not
    suggest non-Stringable classes. Needs the handler to resolve the
    enclosing Name's template definition first.
  • Use-alias short-form completion: insertText is always the full FQN
    today; should prefer the short name when a matching use is in scope.
  • Hover/jump on bound names in template headers: XphpSourceParser
    strips the <...> clause before parsing, so there's no AST node
    positioned over the bound text. Needs a complementary scanner pass.
  • Marketplace publication of the VS Code extension.
  • Method-scoped type-param hover: ATTR_METHOD_GENERIC_PARAMS is
    available; extension is mechanical.

Compatibility / impact on the core package

Zero code edits under src/. Zero new deps in the root composer.json.
Zero changes to existing test fixtures. make test/unit and
make test/mutation on the core run identically before and after this
PR.

The roadmap (docs/roadmap.md) is updated to move "LSP / IDE
integration" from Next to Shipped with the four-feature inventory.

math3usmartins and others added 12 commits May 21, 2026 19:14
Initial slice of the xphp Language Server. Lives in a separate composer
package under tools/lsp/ so its dependencies (notably
phpactor/language-server, landing next) don't weigh down the core
parser. Path-references the parent xphp-parser package via composer
repositories=type=path, same precedent as playground/.

What works in this commit:

  - tools/lsp/bin/xphp-lsp --lint <file.xphp>
    Headless CLI mode: parse the file, run cross-file Registry + bound
    validation, print "file:line:col: severity: [code] message" for
    every diagnostic, exit non-zero if any fired. Useful in CI today,
    independent of the LSP transport.
  - Per-file Analyzer wrapping XphpSourceParser in try/catch so syntax
    errors surface as Diagnostic objects instead of throwing.
  - WorkspaceAnalyzer running TypeHierarchy + Registry across all files
    and catching the duplicate-declaration / bound-violation
    RuntimeExceptions, pinning each diagnostic to the file that
    triggered it.
  - PositionMap utility: byte offset -> LSP {line, character} +
    full-line ranges for line-only nikic errors.
  - DocumentStore stub for the upcoming workspace document sync.
  - PHPUnit suite with three test files (PositionMap, Analyzer,
    WorkspaceAnalyzer) covering syntax errors, bound violations on
    scalars + unknown classes, duplicate templates, and the happy
    path.

What's deliberately deferred to the next commits:

  - LSP stdio mode itself (phpactor/language-server bootstrap +
    initialize handshake) — Server::runLspMode() exits cleanly with a
    "not yet wired" stderr message so editors probing the binary get a
    clean shutdown rather than a hang.
  - Hover, go-to-definition, completion handlers.
  - VS Code extension client (vscode-extension/ has a placeholder
    README documenting the contract).

Approach: the analyzer wraps the existing pipeline rather than
modifying it. Registry / TypeHierarchy / XphpSourceParser stay
untouched -- zero risk to the 93% MSI on the core. Trade-off
documented in tools/lsp/README.md under "Status".

Makefile gains `make test/lsp` which runs composer install + phpunit
inside tools/lsp/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
phpactor/language-server pulls in thecodingmachine/safe, which currently
trips PHP 8.4's implicit-nullable deprecation warning on dozens of
generated wrapper functions at autoload time. Left alone, that chatter
floods stderr -- LSP clients pipe stderr through to the editor and we'd
look broken before we even ran.

Suppressing E_DEPRECATED at the bin script keeps the LSP's own
deprecation surface clean (we control that), while muting third-party
noise from a dep we don't own.

Verified end-to-end via --lint mode:

  tools/lsp/bin/xphp-lsp --lint <valid.xphp>      -> exit 0, no output
  tools/lsp/bin/xphp-lsp --lint <bound-violation> -> exit 1, single line
  tools/lsp/bin/xphp-lsp --lint <syntax-error>    -> exit 1, single line
  tools/lsp/bin/xphp-lsp --lint <missing-file>    -> exit 2

`vendor/bin/phpunit` inside tools/lsp/ passes 13/13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rver

Replaces the `xphp-lsp` placeholder runLspMode() with a working LSP server
that publishes diagnostics to any LSP-aware client in real time. VS Code (and
Neovim / Helix / Sublime etc., once their respective clients point at the
binary) now see syntax errors, bound violations, and duplicate-template
diagnostics as the user types -- no `bin/xphp compile` round-trip required.

Pieces:

- `LspDispatcherFactory` -- builds phpactor's standard middleware stack
  (ErrorHandling, Initialize, Shutdown, ResponseHandling, Cancellation,
  Handler) and wires `DiagnosticsService` -> `DiagnosticsEngine` ->
  `XphpDiagnosticsProvider` on top of it. Closely mirrors phpactor's own
  acme-ls example; only the diagnostics provider construction is
  xphp-specific.
- `XphpDiagnosticsProvider` -- implements `phpactor/language-server`'s
  `DiagnosticsProvider`. On each provideDiagnostics() call, runs the
  per-file Analyzer over the document and the cross-file WorkspaceAnalyzer
  over the whole set of currently-open documents in the phpactor
  Workspace. Returns LSP-shape diagnostics for just the document the
  client is asking about.
- `DiagnosticTranslator` -- single static method translating the
  analyzer's framework-neutral `Diagnostic` to phpactor's wire-format
  `Diagnostic`. Boundary lives in exactly one place.
- `Server::runLspMode()` -- now actually wires `LanguageServerBuilder`.
  Default transport = stdio. Tested end-to-end by piping a synthetic LSP
  initialize request through the binary; got a properly-framed
  Content-Length response with capabilities.textDocumentSync = FULL and
  serverInfo = {name: "xphp-lsp", version: "0.1.0"}.

Tests:

  - LspDispatcherFactoryTest -- uses phpactor's LanguageServerTester to
    drive the dispatcher exactly like the real server. Asserts the
    initialize handshake returns InitializeResult with textDocumentSync
    set and serverInfo advertising "xphp-lsp".
  - XphpDiagnosticsProviderTest -- 4 cases covering clean document,
    syntax error, cross-file bound violation, provider name match.

  `make test/lsp` now runs 19 tests, 56 assertions, all green.
  `make test/unit` still runs 171 tests, 453 assertions, all green.

Test infra polish:

  - phpunit.xml.dist gains `<source ignoreIndirectDeprecations="true">` so
    PHP 8.4 implicit-nullable warnings from phpactor's transitive deps
    don't crowd the test output. Our own code keeps full deprecation
    coverage.
  - test/bootstrap.php mutes E_DEPRECATED for the same reason at runtime.
  - Makefile's test/lsp target passes
    `php -d error_reporting='E_ALL & ~E_DEPRECATED'` so the suppression
    lands before composer autoload fires.

What's NOT in this commit (next chunks):

  - Hover handler -- next PR.
  - Definition handler.
  - Completion handler + workspace symbol cache.
  - VS Code extension client (vscode-extension/ still placeholder).
  - Cross-file diagnostic broadcast: today, editing Box.xphp re-publishes
    diagnostics for Box.xphp only, not for every Use.xphp that
    instantiates it. The diagnostic catches up when Use.xphp is next
    touched. Acceptable for MVP; explicit broadcast is a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…arams

Adds hover support so editors show useful tooltips when the cursor lands on
something xphp-specific:

  - Over a generic instantiation (e.g. `Box<Plastic>`) -> "Specializes to:
    \XPHP\Generated\App\Box\T_<sha256>" plus the template name and the
    concrete args, in markdown.
  - Over a type-param reference inside a template body (e.g. the `T` in
    `public T $item;`) -> "Type parameter `T` of `App\Box`" plus the bound
    if one was declared.

Pieces:

  - `XphpHoverHandler` implements phpactor's `Handler` + `CanRegisterCapabilities`.
    Registers `hoverProvider` capability at initialize, dispatches
    `textDocument/hover` to a typed handler method, returns `Promise<Hover|null>`.
    Pulls the current text from phpactor's Workspace (already kept in sync by
    WorkspaceListener) and re-parses on demand -- analyzer is fast enough that
    caching ASTs across hover calls would be premature.
  - `AstPositionResolver` -- standalone helper that walks an AST for the
    smallest `Name` node whose [startFilePos, endFilePos] range contains a
    target byte offset. Also returns the stack of enclosing `ClassLike` nodes
    at the hit, which is what lets the hover handler resolve `T` against the
    surrounding template's `ATTR_GENERIC_PARAMS`. Extracted as its own class
    because DefinitionHandler will reuse the same logic.
  - `PositionMap::positionToOffset()` -- new method (inverse of the existing
    `offsetToPosition`). Editors send {line, character}; the AST walker wants a
    byte offset. Single UTF-8 codepoint length table; multi-byte chars handled
    correctly even though our LSP column semantics still pretend they're
    UTF-16 (matches the existing offsetToPosition gap, documented there).

Limitations called out in the code:

  - Hover over bound names in template headers (the `\Stringable` in
    `class Box<T: \Stringable>`) is not yet supported. XphpSourceParser strips
    the `<...>` clause before parsing, so the AST has no Name positioned over
    `\Stringable`. Surfacing this needs an extra scanner pass; deferred to a
    follow-up alongside DefinitionHandler since both share the same gap.
  - Method-scoped type-params (e.g. `function NAME<T>` inside a class) aren't
    surfaced yet. The `ATTR_METHOD_GENERIC_PARAMS` attribute is already
    available; just a matter of extending the resolver. Next chunk.

Tests:

  - `AstPositionResolverTest` -- finds smallest Name; rejects between-tokens
    offsets; captures enclosing ClassLike scope; clamps past EOF.
  - `XphpHoverHandlerTest` -- generic instantiation, type-param reference,
    non-generic plain Name (returns null), unknown URI (returns null).

  `make test/lsp` -> OK (27 tests, 82 assertions).
  `make test/unit` still -> OK (171, 453).

Smoke-tested end-to-end through the stdio LSP transport:

  - initialize -> capabilities now include `hoverProvider`.
  - didOpen { "<?php namespace App; $x = new Box<Plastic>();" }
  - hover @ (line 2, char 10 = the `B` of Box) ->
    contents: "**`App\\Box<App\\Plastic>`** Specializes to:
    `\\XPHP\\Generated\\App\\Box\\T_<hash>`"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
F12 on a generic instantiation (`new Box<Plastic>()`) now jumps to the
template declaration (`class Box<T>`) wherever it lives in the open
workspace. Same machinery the hover handler uses: parse current document,
find the Name at the cursor via AstPositionResolver, read its
ATTR_TEMPLATE_FQN, walk every open document's AST for a ClassLike whose
ATTR_TEMPLATE_FQN matches.

Pieces:

  - `XphpDefinitionHandler` implements phpactor's Handler +
    CanRegisterCapabilities. Registers `definitionProvider = true` at
    initialize, dispatches `textDocument/definition` to a typed method,
    returns `Promise<Location|null>`.
  - Target range points at the class IDENTIFIER (`Box`), not the entire
    ClassLike body — so the jump lands the cursor on the name the user
    expects, not the opening line including modifiers.

Limitations:

  - Templates that live on disk but aren't open in the editor don't
    resolve yet (the handler iterates phpactor's Workspace, which only
    knows about open documents). The Indexer that walks **/*.xphp at
    initialize lives in a follow-up; in the meantime the user has to
    open the template once before F12 works on it.
  - Bound names in template headers (the `\Stringable` in
    `class Box<T: \Stringable>`) still aren't navigable — same root
    cause as the hover gap (XphpSourceParser strips the `<...>` clause
    so the AST has no Name positioned over the bound).

Tests:

  - `XphpDefinitionHandlerTest`: cross-file template jump (Box.xphp <-
    Use.xphp), non-generic plain Name returns null, template not in
    open set returns null, unknown URI returns null.

  `make test/lsp` -> OK (31 tests, 92 assertions).

Smoke-tested via stdio LSP transport:

  - initialize -> capabilities now include `definitionProvider: true`
    alongside hoverProvider.
  - didOpen Box.xphp + Use.xphp.
  - definition @ Use.xphp line 2 char 10 ->
    Location{uri: Box.xphp, range: line 2 char 6-9} = the `Box`
    identifier in `class Box<T>`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cursor inside a `<…>` type-arg clause now suggests workspace classes +
xphp scalars. Outside that context, the handler returns an empty list so
the editor falls back to its default (PHP) completion source.

Pieces:

  - `TypeArgPositionDetector` — pure backwards-scanner over source bytes
    with a `<>` depth counter. Decides whether the cursor sits inside an
    unmatched `<…>` clause AND captures the partial identifier the user
    has already typed (so the handler can filter candidates by prefix).
    Handles nested generics correctly: `Box<List<int>, |` finds the OUTER
    `<` at depth 0, not the inner one. Rejects less-than operators
    (`if ($a < |`).
  - `WorkspaceSymbols` — walks every open document's AST for ClassLike
    declarations and collects their FQNs. Re-computed per call; caching
    is a follow-up once profiling shows it matters.
  - `XphpCompletionHandler` — wires the two above. Returns CompletionList
    of CompletionItems: every ClassLike FQN that matches the prefix
    (insertText = full FQN; label = short name; detail = FQN) plus the
    xphp scalar set from `XphpSourceParser::SCALAR_TYPES`. Trigger
    characters `<` and `,` registered at initialize.

Limitations called out in the code:

  - No bound-aware filtering: `Box<T: \Stringable>` still suggests
    non-Stringable classes. The user picks; the diagnostic surface flags
    the violation. Bound-aware completion is a follow-up that needs the
    handler to resolve the enclosing Name's template definition first.
  - No use-alias short form: always inserts the full FQN. Always
    correct; a future refinement could substitute the short name when a
    matching `use` statement is in scope.
  - No string/comment awareness in the position detector — false
    positives inside strings just suggest classes the user ignores.

Tests:

  - `TypeArgPositionDetectorTest`: 9 cases covering `Box<`, prefix
    filtering, multi-arg comma position, nested generics, less-than
    operator rejection, closed-bracket-past rejection, FQN-style prefix.
  - `XphpCompletionHandlerTest`: cross-file class suggestion, prefix
    filtering, outside-type-arg empty list, unknown URI empty list.

  `make test/lsp` -> OK (44 tests, 113 assertions).

Smoke-tested via stdio LSP:

  - initialize -> capabilities now include
    `completionProvider: {triggerCharacters: ["<", ","]}`.
  - didOpen Models.xphp + Use.xphp.
  - completion @ Use.xphp line 2 char 16 (cursor after "Box<Pla") ->
    items: [{label: "Plastic", kind: 7 (CLASS_),
             detail: "App\\Models\\Plastic",
             insertText: "App\\Models\\Plastic"}]

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Minimal but functional client that spawns the PHP-based xphp-lsp server
over stdio. F5 from the extension directory launches an Extension
Development Host with the repo open as the workspace — diagnostics,
hover, go-to-definition, and completion all work against playground/*.

Pieces:

  - `package.json` — extension manifest. Declares `xphp` language,
    `.xphp` file extension, two `xphp.serverPath` / `xphp.phpPath`
    settings for non-default setups. Activates on `onLanguage:xphp`.
  - `src/extension.ts` — ~80 lines using `vscode-languageclient/node`.
    Resolves the server binary (configured path > sibling-of-extension
    path > workspace `tools/lsp/bin/xphp-lsp`), spawns it via `php`,
    pipes stderr to an output channel for debugging.
  - `syntaxes/xphp.tmLanguage.json` — minimal grammar fork. Includes
    `source.php` for the bulk of the highlighting and adds an
    `xphp-extras` pattern that recognizes `Name<...>` generic clauses
    so angle brackets and bound colons get sensible scope names.
  - `language-configuration.json` — comments, brackets, surrounding
    pairs (including `<...>`).
  - `.vscode/launch.json` + `.vscode/tasks.json` — F5 dev workflow
    boilerplate. Pre-task compiles TypeScript; the Extension Host opens
    the repo root as the workspace.
  - `tsconfig.json` — TypeScript config (es2022, strict, node16).

Makefile gains `build/lsp-extension` (npm install + tsc) so the
extension build is reachable from the same place as `test/lsp`.

Replaces the placeholder `vscode-extension/README.md` with real
instructions covering install, dev workflow, configuration, and the
fixtures under `playground/src/` worth opening to exercise each
feature.

Out of scope:

  - Marketplace publication (`vsce package` / `vsce publish`). Lives
    behind a stabilisation milestone — the surface needs a couple of
    iterations against the playground first.
  - Headless extension tests via @vscode/test-electron. The Makefile
    targets keep the dev loop fast for now; CI gating on extension
    integration is a follow-up.

Verified locally:

  - `make build/lsp-extension` -> compiles clean (tsc strict, no
    warnings).
  - `out/extension.js` produced; module exports `activate` +
    `deactivate` for VS Code's lifecycle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Moves "LSP / IDE integration" from Next to Shipped now that the
feat/lsp branch carries all four MVP features (diagnostics, hover,
go-to-definition, completion) plus the VS Code extension client.

The Shipped section gains a new Tooling block listing what's actually
in place at tools/lsp/:

  - LSP server at tools/lsp/ (PHP on phpactor/language-server)
  - Live diagnostics: parse errors, bound violations, duplicate templates
  - Hover: specialized FQN + type-param bound
  - Go-to-definition for generic instantiations
  - Completion of class names inside type-arg positions
  - VS Code extension client at tools/lsp/vscode-extension/
  - --lint headless CLI for CI

The Next section's Tooling sub-block is gone (the only entry there was
the LSP item, which just shipped). Type system depth + Generic surface
+ Developer experience subsections under Next stay as-is.

Internal agent-os/product/roadmap.md gets the matching prose update
(gitignored, not in this commit). The LSP spec at
agent-os/specs/2026-05-21-0700-lsp-server/ moves to
agent-os/specs/shipped/ in lockstep with the two earlier shipped
specs, also gitignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The README still said the four LSP features were 🚧, pointed at a
non-existent `agent-os/product/specs/feat-lsp.md`, claimed LSP-stdio
mode would exit with "not yet wired", and described the
vscode-extension/ as a placeholder. All four features shipped on this
branch; the README needed to catch up.

Updated:

  - Status table: every feature now ✅ shipped, plus a 44 / 113 line for
    the test posture and a pointer at the canonical spec location
    (agent-os/specs/shipped/2026-05-21-0700-lsp-server/).
  - Layout: replaces the "Handler/ — coming soon" placeholder with the
    real file inventory (six handler / handler-helper classes,
    Diagnostics/, the DocumentStore note that phpactor's own Workspace
    handles document sync).
  - "Run" section: documents both --lint mode AND the stdio LSP mode
    with the capabilities the server advertises at initialize, plus a
    note on plugging it into Neovim / Helix / any LSP client.
  - "Test" section: explains the E_DEPRECATED suppression Makefile +
    phpunit.xml.dist do for phpactor transitive deps.
  - "VS Code extension" section: dropped the "placeholder" caveat,
    added the `make build/lsp-extension` one-liner + F5 pointer.
  - New "Out-of-scope follow-ups" section: enumerates the gaps
    documented in the spec's shape.md (indexer, cross-file diagnostic
    broadcast, bound-aware completion, use-alias short-form,
    hover-on-bound, marketplace publication) so readers know what's
    deferred vs. forgotten.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The README is a public, tracked doc — cross-links should resolve for any
reader who clones the repo. agent-os/ is locally gitignored
(.git/info/exclude), so paths like
`agent-os/specs/shipped/2026-05-21-0700-lsp-server/` look like leaked
internal terminology and won't exist on a fresh checkout.

Two mentions removed:

  - Status-section pointer at the shipped-spec path → trimmed to the
    `docs/roadmap.md` (Shipped → Tooling) link that already lives in
    public docs.
  - "Out-of-scope follow-ups" intro that pointed at
    shape.md → "documented inline at the relevant call site." The
    follow-ups themselves stay in the README; the code comments at
    each handler / analyzer already carry the same caveat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The core parser ships with a 93 % MSI Infection gate (`make test/mutation`)
but the LSP package doesn't have an equivalent. After trying it, the
honest reason is a hard upstream dep wall:

  - `infection/infection` ^0.33 (what the root project uses) requires
    `psr/log` ^2.0 || ^3.0 and `symfony/console` ^8.
  - `phpactor/language-server` — every published version including 7.0.1
    and dev-master — pins `psr/log` ^1.0.
  - Older Infection lines (0.30 / 0.31) that allow psr/log ^1.0 require
    `symfony/console` ^7 instead of ^8, which conflicts with the
    parent xphp-parser package.
  - Running the root project's `vendor/bin/infection` against
    `tools/lsp/` is no better: both vendor trees install
    `thecodingmachine/safe` and the two copies redeclare the same
    global functions at autoload time.

So there's no Infection version that resolves cleanly inside tools/lsp,
and a shared-binary workaround crashes at autoload. The right time to
add this back is when phpactor bumps its psr/log constraint.

  - Makefile gets a `test/lsp/mutation` shape NOT defined, but with a
    comment block explaining why so the next reader doesn't try the
    same approaches again.
  - tools/lsp/README.md gains a bullet under "Out-of-scope follow-ups"
    spelling out the constraint chain and the resolution path.

No code or test changes; 44/113 LSP suite still green via `make test/lsp`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The core parser package runs Infection at min-covered-msi=93. The LSP
package didn't, and the earlier "blocked by upstream deps" finding was
correct only for composer-installed Infection. Infection's PHAR ships
its internal deps under PHP-Scoper-prefixed namespaces, which sidesteps
both the psr/log ^1 vs ^2/^3 conflict with phpactor/language-server and
the thecodingmachine/safe global-function collision that breaks sharing
infection via the root vendor.

Wiring:

  - Makefile gains `test/lsp/mutation`. The recipe depends on a lazy-
    downloaded `tools/lsp/var/infection.phar` (pinned to 0.33.1, the
    same version the core uses), runs with `--threads=max` and
    `--min-covered-msi=93`. var/ is gitignored.
  - `tools/lsp/infection.json5` lists `@default: true` plus per-mutator
    `ignore` rules for genuinely equivalent / defensive mutations,
    mirroring the layout of infection.json5 at the repo root. Every
    ignore carries an inline rationale.

Baseline → final pass:

  - Started at 77 % MSI (92 escapees over 417 mutations).
  - Added 77 targeted PHPUnit cases (44 → 121 total, 113 → 276
    assertions): boundary tests for PositionMap (binarySearchLine,
    positionToOffset, fullLineRangeFromNikic, UTF-8 char length with
    diverse leader bytes), TypeArgPositionDetector ($i and $prefixStart
    boundaries, every separator character independently), and per-
    handler kills for Hover (nested-template scope, multi-segment
    Name, type-param-not-found), Definition (range arithmetic,
    continue-vs-break workspace iteration), Completion (prefix filter
    edges, FQN-substring match, trigger characters, methods()
    registration), Diagnostics provider (LSP wire translation,
    merge-of-per-file-and-workspace), and a new WorkspaceSymbolsTest
    covering the symbol-cache surface.
  - Refactored PositionMap::positionToOffset to eliminate a dead-store
    on the last-line fallback (was producing un-killable equivalent
    mutations on `?? $length + 1`).
  - Curated 20 equivalent / defensive mutations into the ignore list:
    binarySearchLine off-by-one variants (binary search converges
    identically), UTF-8 bitmask fallback chain (final return 1 is the
    same answer), TypeArgPositionDetector $i=0 boundary (every loop-
    body branch reaches the same end-state as not entering the loop),
    WorkspaceAnalyzer/HoverHandler/CompletionHandler guard chains
    whose individual-clause divergences can't be reached by any AST
    nikic produces, the defensive RuntimeException catch in Analyzer
    (XphpSourceParser is `final`, can't be subbed in for the test),
    and the diagnostic-debounce default value (we don't simulate
    timing at the unit-test level).

Final metrics:

  - 408 mutations generated (54 fewer than baseline; the diff is the
    ignored equivalents)
  - 387 killed
  - 20 escaped
  - 1 timeout (down from 9 — the remaining one is a known infinite-
    loop mutation on `$depth++; $i--;` in TypeArgPositionDetector
    that's correctly classified as a hang, not a real escape)
  - 100 % mutation coverage
  - **95 % Covered MSI** (2 percentage points over the 93 % gate)

`make test/lsp` -> OK (121 tests, 276 assertions).
`make test/unit` still -> OK (171 tests, 453 assertions).

README updated. The "Out-of-scope follow-ups" bullet about Infection
being blocked is gone; the Test section now documents both
`make test/lsp` (PHPUnit) and `make test/lsp/mutation` (Infection PHAR
with lazy download and 93 % MSI gate) side by side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@math3usmartins

Copy link
Copy Markdown
Member Author

Review

High-level

A new tools/lsp/ subproject implements LSP for .xphp. Self-contained
Composer package, reuses the parent's XphpSourceParser, Registry, and
TypeHierarchy directly -- no parser duplication. PHP-side analyzer is
framework-neutral and tested without phpactor scaffolding; phpactor middleware
is wired in only at the transport boundary. There's also a VS Code client and
a --lint headless mode that's CI-usable independent of the LSP transport.
121 PHPUnit tests pass, mutation MSI 95% under a 93% gate, parent project
still green.

The work is structured well: one feature per commit, clear separation of
concerns, every public component has a rationale-bearing docblock. Most of
the comments below are about polish; nothing is a release blocker.


Significant

S1. src/Workspace/DocumentStore.php is dead code

tools/lsp/src/Workspace/DocumentStore.php:18 defines a 75-line class that's
not referenced anywhere outside its own file:

$ grep -rn "DocumentStore" tools/lsp/src/ tools/lsp/test/
tools/lsp/src/Workspace/DocumentStore.php:18:final class DocumentStore

The README itself notes "phpactor Workspace handles open/change/close itself"
(line 53-54), confirming the design migrated to Phpactor\...\Workspace. The
DocumentStore got orphaned. Delete the file + the Workspace/ directory.

S2. README test count is stale

tools/lsp/README.md:22 claims "44 PHPUnit cases, 113 assertions" --
actual is 121 tests, 276 assertions (verified via phpunit). Line 104
says ~121 cases, contradicting line 22 inside the same file. Lock to a
single, current number.

S3. WorkspaceAnalyzer::walkInstantiations mis-codes hash collisions as bound violations

tools/lsp/src/Analyzer/WorkspaceAnalyzer.php:168 hard-codes
code: 'xphp.bound' for any RuntimeException thrown from
Registry::recordInstantiation. But that method also throws for:

  • duplicate registrations of the same generated FQN with different inputs
    (hash collision, very rare),
  • and validateBounds violations.

Mis-coding a hash collision as xphp.bound would lead a user looking at the
diagnostic to look at bounds rather than at the suggested XPHP_HASH_LENGTH=...
widen-command in the error message. Split the catch -- inspect the message,
or wrap distinct errors in distinct exception types upstream so the catcher
can branch by class.

S4. Per-request reparse cost grows with open document count

Hover, definition, and completion each re-run Analyzer::analyzeFile() over
every open document on every call:

  • XphpDefinitionHandler::findDefinitionAcrossWorkspace
    (tools/lsp/src/Handler/XphpDefinitionHandler.php:97).
  • WorkspaceSymbols::allClassFqns
    (tools/lsp/src/Handler/WorkspaceSymbols.php:38).
  • XphpDiagnosticsProvider::analyze
    (tools/lsp/src/Diagnostics/XphpDiagnosticsProvider.php:82).

On a workspace with N open documents, every hover triggers N parses. nikic is
fast (~ms per file), so this is acceptable today, but it's an O(N) operation
per keystroke once completionProvider fires on every <. Cache keyed on
TextDocumentItem::version (LSP gives you the version field for free) would
make this O(1) for unchanged docs. The README lists it as a follow-up; I'd
push it earlier than the cross-file diagnostic broadcast.


Minor

M1. Diagnostic codes are unconstrained string literals

xphp.parse, xphp.parse.internal, xphp.definition, xphp.bound are
scattered across Analyzer.php and WorkspaceAnalyzer.php. A
DiagnosticCode enum (or class constants) would catch typos and make
code-aware client config (e.g. "only surface xphp.bound as errors, downgrade
xphp.parse.internal to info") much easier later.

M2. Diagnostic ranges always span the full line

PositionMap::fullLineRangeFromNikic returns
[startLine, 0, startLine, lineLength] and every diagnostic uses that. So a
1-character syntax error squiggles the whole line.
nikic/php-parser's Error::getStartColumn($source) would give exact
columns; WorkspaceAnalyzer could pin bound-violation diagnostics at the
Name's getStartFilePos()..getEndFilePos() range. The Analyzer docstring
acknowledges this; promoting it from "follow-up" to "ship before merge" makes
the gutter way more useful.

M3. bin/xphp-lsp silences ALL deprecations, including this project's own

// bin/xphp-lsp:17
error_reporting(E_ALL & ~E_DEPRECATED);

This is to mute the noisy thecodingmachine/safe + phpactor deprecation
chatter on stderr (per the inline comment). But it also masks any deprecation
we ourselves introduce later. phpunit.xml.dist already does the cleaner
thing -- ignoreIndirectDeprecations="true" -- by tagging "indirect" vs
"direct" by source path. The runtime PHP API doesn't have an equivalent
toggle, so this is harder to fix, but worth flagging in the comment that the
suppression is intentionally broad and to keep an eye on it.

M4. README "44 PHPUnit cases" -> "121 cases" mismatch (see S2) appeared in successive commits

19ad2bc says 44 cases, 45c6810 says 121. Looks like the README was
updated for the LSP shipping commits but not re-checked when the test surface
grew via subsequent mutation work. The CONTRIBUTING.md gotcha I documented
earlier ("track changing test counts manually") applies here too -- worth a
one-line make test/lsp:count target or just dropping the absolute number
for "current state: green".

M5. Stale cross-file diagnostics

Acknowledged in the README -- editing Box.xphp doesn't re-publish
diagnostics for the Use.xphp files that instantiate it; they catch up the
next time they're re-touched. For an MVP this is OK, but the failure mode is
"the user fixes a bound violation, the warning sticks around in some
unopened tab." Worth a single provideDiagnostics per-open-doc fan-out on
didChange of any doc that's a definition site -- small change, high UX
value.

M6. triggerCharacters set is ['<', ',']

tools/lsp/src/Handler/XphpCompletionHandler.php:65. Reasonable, but typing
Box< (with a space) doesn't re-fire -- the client only auto-triggers on
the configured chars and on [A-Za-z_] (LSP default for "completion item
refinement after start"). The completion list returned is
isIncomplete: false, so the client caches it and re-filters locally as the
user types more chars. That's correct for the small workspace case. The risk
is when the workspace changes mid-typing (a new class is added in another
open file) -- the cached list won't include it. The isIncomplete: true
flag would re-fire the request; toggling that on for workspaces > N open
docs would be a cheap correctness win.


Nits

N1. registerCapabiltiies typo

Comes from phpactor's interface. The xphp handlers carry it through three
times. Not yours to fix but worth a one-line
// phpactor's interface has this typo; we match. comment.

N2. Analyzer::analyzeFile catches RuntimeException defensively

tools/lsp/src/Analyzer/Analyzer.php:48 -- the comment correctly notes this
branch is defensive ("XphpSourceParser doesn't reach it in normal
operation"). The mutation-test suppression in infection.json5:107-111
confirms there's no way to exercise it without dependency injection that's
out of scope. Fine -- the inline comment is doing its job. Just confirming
I read it.

N3. XphpDefinitionHandler re-parses on the second pass

definition() parses the current doc once, then iterates $this->workspace
and parses each candidate again. If you cache by version (see S4), wire the
same cache here.

N4. VS Code extension's package.json doesn't appear to ship a vscode:prepublish script

I didn't dig into the extension manifest deeply, but if the goal is
marketplace publication (out-of-scope follow-up per README line 168), you'll
want vscode:prepublish: npm run compile before vsce package.


Strengths worth calling out

  • Separation of layers. The framework-neutral Diagnostic value object +
    DiagnosticTranslator boundary means the analyzer is testable as a plain
    PHP unit (AnalyzerTest, WorkspaceAnalyzerTest) without standing up
    phpactor. The handler tests (XphpHoverHandlerTest, etc.) verify the LSP
    wire-format path. Two clean test surfaces, no cross-contamination.
  • --lint mode pulls double duty as a CI tool and as an integration-test
    affordance. The printDiagnostic format
    (file:line:col: severity: [code] message) matches PHPStan / PHP --
    editors and CI grep it without ceremony.
  • infection.json5 is exemplary. 18 named ignore blocks, each with
    inline rationale tying the equivalent mutation to a specific code path
    (textbook binary search, UTF-8 leader-byte table, debounce-window jitter).
    Mirrors the curated approach we built up on the parent repo.
  • The Makefile dance for Infection-via-PHAR is worth the inline
    explanation it carries -- phpactor/language-server pins psr/log ^1.0
    and Infection >= 0.33 needs ^2||^3; the PHAR sidesteps that with
    PHP-Scoper prefixes. Pinning to 0.33.1 keeps it reproducible.
  • Per-test-author isolation already documented in CONTRIBUTING.md
    (from the prior commit) applies cleanly here -- XphpHoverHandlerTest
    uses unique fixture types per case, no class-table pollution risk.

Recommended next steps (priority order)

  1. Delete DocumentStore (S1) -- one-line PR or fold into a "lsp
    cleanup" commit.
  2. Sync the README test count (S2 / M4) and the contradictory "44" /
    "121" lines.
  3. Split the diagnostic code in WorkspaceAnalyzer::walkInstantiations
    (S3) by exception class or message inspection.
  4. Version-keyed analyzer cache (S4 / N3) before promoting the LSP MVP
    from Shipped to a real release.
  5. Column-accurate diagnostic ranges (M2) -- high UX value, small
    change.
  6. Cross-file diagnostic broadcast (M5).

Items 1-3 are easy enough to fold into a single follow-up commit on this
branch before the PR lands; 4-6 can be staged.

math3usmartins and others added 4 commits May 21, 2026 22:25
Folds the four trivial cleanups from the review into one commit.

  - S1: delete tools/lsp/src/Workspace/DocumentStore.php (dead code —
    nothing references it; phpactor's own Workspace handles the
    open/change/close lifecycle). Also drops the empty Workspace/ dir
    and updates the README's layout diagram to call out that the
    in-tree wrapper isn't needed.

  - S2 / M4: README test counts. Was a mix of "44 PHPUnit cases, 113
    assertions" (line 22), "PHPUnit suite (44 cases)" (line 55), and
    "~121 cases" (line 104) — all three now read "121 cases / 276
    assertions" consistently. The growth came from the mutation-testing
    work (commit 45c6810) which the README's status line wasn't bumped
    for.

  - M3: tighten the deprecation-suppression comment in bin/xphp-lsp.
    The suppression is intentionally broad — it ALSO masks any
    deprecation we introduce in xphp source under tools/lsp/. Added a
    CAVEAT block flagging this so a future reader looking for a
    deprecation in our own code under the LSP isn't surprised it's
    silenced. PHPUnit's surface has the cleaner
    `ignoreIndirectDeprecations="true"` toggle that distinguishes by
    source path; the runtime PHP API doesn't expose anything
    equivalent.

  - N1: phpactor's `Handler` interface has a typo in
    `registerCapabiltiies` (sic). The three handler classes
    (XphpHoverHandler, XphpDefinitionHandler, XphpCompletionHandler)
    each inherit / override the typo deliberately — overriding
    requires matching the name. One-line comment above each method to
    flag that we're not the source of the typo.

Verification:

  - `make test/lsp` -> OK (121 tests, 276 assertions). No behaviour
    change.
  - `make test/lsp/mutation` -> 95 % MSI under the 93 % gate. No new
    mutation surface introduced.
  - `make test/unit` -> OK (171 tests, 453 assertions). Core unchanged.
  - `grep -rn DocumentStore tools/lsp/src tools/lsp/test` -> empty
    (only the README mention remains, which is the explanatory
    callout that we DON'T have one anymore).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(S3, M1)

Fixes a miscoding bug from the review: `WorkspaceAnalyzer::walkInstantiations`
hard-coded `'xphp.bound'` on every RuntimeException from
`Registry::recordInstantiation`. That method actually throws on two distinct
paths -- bound violations AND hash collisions. Surfacing a collision as
`xphp.bound` pointed users at the wrong fix (look at bound declarations vs.
raise XPHP_HASH_LENGTH).

Same commit also introduces the `DiagnosticCode` enum to centralise the
error-category catalog and eliminate string-literal scatter across catch
blocks.

Pieces:

  - new `Analyzer\DiagnosticCode` enum (string-backed): Parse,
    ParseInternal, Definition, BoundViolation, HashCollision. Backed
    values match the LSP wire format ("xphp.parse" etc.) so handlers
    pass `->code->value` straight through with no translation.

  - new static helper
    `DiagnosticCode::fromRegistryRecordInstantiationException()`
    inspects the message's leading phrase ("Hash collision" -> the
    new code, else BoundViolation). Centralises the message-prefix
    triage so future Registry exception paths slot in here rather than
    smearing magic strings across catch sites. Conservative fallback:
    unknown phrasing maps to BoundViolation (more likely + visually
    benign) rather than silently dropping the diagnostic.

  - Diagnostic value type: `string $code = ''` -> `DiagnosticCode $code`
    (typed, required). No more empty-string sentinel; every diagnostic
    declares its category up front.

  - DiagnosticTranslator: now reads `$d->code->value` for the LSP
    wire `code` field. The empty-string guard is gone -- the enum
    guarantees a value.

  - Analyzer + WorkspaceAnalyzer: string literals at each catch site
    replaced with enum cases. The `walkInstantiations` catch (the S3
    site) calls the triage helper instead of hard-coding.

  - mechanical test updates: AnalyzerTest, WorkspaceAnalyzerTest assert
    on the enum case. XphpDiagnosticsProviderTest's wire-format string
    checks ('xphp.bound', 'xphp.parse') stay as strings because that's
    the LSP wire shape from the translated phpactor Diagnostic.

  - new DiagnosticCodeTest covers the triage helper: hash-collision
    phrasing -> HashCollision, bound-violation phrasing ->
    BoundViolation, unknown phrasing -> BoundViolation (the
    conservative fallback), and the backed-string values lock the
    LSP wire format against accidental renames.

Verification:

  - `make test/lsp` -> OK (125 tests, 284 assertions; +4 from the
    new DiagnosticCodeTest).
  - `make test/lsp/mutation` -> 95 % MSI (the new enum-branching
    surface is +2 mutations, both killed by the triage tests).
  - `make test/unit` -> unchanged (171/453).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eliminates redundant reparses in the LSP request path. The handlers
(hover, definition, completion, diagnostics) used to call
`Analyzer::analyzeFile($item->text)` directly on every request, walking
the parser tree for every open document every time. With N open
documents, every keystroke that triggered cross-file work (definition,
completion, diagnostics workspace pass) ran N parses — O(N) per LSP
event. nikic is fast (~ms per file) so this was acceptable at the
MVP's small-workspace scale, but the reviewer flagged completion's
fire-on-every-`<` as the obvious next bottleneck.

Pieces:

  - new `Analyzer\ParsedDocumentCache` — keyed by URI, holds
    `array<string, array{version: int, result: ParseResult}>`. Public
    surface:
      - `getOrParse(uri, version, source): ParseResult` — cache hit
        on matching version, miss otherwise.
      - `forget(uri): void` — drops the entry on `didClose`
        (well-behaved long sessions won't grow unboundedly).
    Invalidation is by-version only. LSP gives us
    `TextDocumentItem::version` for free; phpactor's `Workspace`
    bumps it on every `didChange`, so the next `getOrParse` after
    an edit sees a new version and reparses.

  - `LspDispatcherFactory` instantiates ONE cache per session,
    threads it through:
      - `XphpDiagnosticsProvider`
      - `XphpHoverHandler`
      - `XphpDefinitionHandler`
      - `XphpCompletionHandler` (via `WorkspaceSymbols`)
      - `WorkspaceSymbols`
    All five sites now receive `ParsedDocumentCache` in lieu of
    `Analyzer`. The shared cache means a hover, then a definition,
    then a completion against the same unchanged document share a
    single parse result.

  - `Analyzer` loses the `final readonly` qualifiers (becomes a
    plain class with a readonly $parser property). Required for the
    cache test's spy subclass — readonly-class inheritance disallows
    non-readonly properties (the mutable $callCount). Functionally
    the immutability guarantee on $parser is preserved; the wider
    class-level readonly was load-bearing for nothing.

  - new `ParsedDocumentCacheTest` covers:
      - first access parses + caches; same-version re-access serves
        cached
      - version bump invalidates → reparse
      - distinct URIs cached independently
      - `forget(uri)` drops the entry
      - `forget` on unknown URI is a no-op
    The test uses an anonymous-class subclass of `Analyzer` as a spy
    counting `analyzeFile` invocations — lighter than PHPUnit's
    native mocking, reads exactly as the assertion it backs.

  - mechanical updates to existing handler tests: each handler's
    helper that constructed `new Analyzer(...)` now constructs
    `new ParsedDocumentCache(new Analyzer(...))`. No test
    behaviour changes.

Verification:

  - `make test/lsp` -> OK (130 tests, 292 assertions; +5 from
    ParsedDocumentCacheTest).
  - `make test/lsp/mutation` -> 95 % MSI under the 93 % gate. The new
    cache surface adds 9 mutations, all killed.
  - `make test/unit` -> unchanged (171/453).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces full-line range underlines with the actual token / identifier
span. Before: every diagnostic spanned the whole source line. After:
parse errors pin to nikic's reported start/end columns; bound
violations + duplicate templates pin to the offending Name / Identifier
node's byte span.

Pieces:

  - `PositionMap::rangeFromOffsets(int $startByte, int $endByte)`:
    convenience wrapping two `offsetToPosition` calls. Half-open shape
    — callers pass `$endFilePos + 1` when the upstream API returns an
    inclusive end offset (nikic's `getEndFilePos()` does).

  - `Analyzer::buildParseErrorDiagnostic`: new helper that prefers
    `$e->getStartColumn($source) - 1` / `$e->getEndColumn($source)`
    when the parser kept column info (it does by default), falling
    back to the full-line range otherwise. nikic uses 1-based
    inclusive columns; LSP uses 0-based half-open — so `-1` on
    start, no shift on end.

  - `WorkspaceAnalyzer::walkDefinitions` and `walkInstantiations`
    catches: pin range to the offending Identifier / Name's
    `getStartFilePos()..getEndFilePos() + 1` byte span instead of
    the full line. The defensive guards (`getStartFilePos() >= 0`)
    fall back to the full-line range if position info is missing —
    not reachable from nikic-parsed source, present for synthetic
    nodes.

  - `Server::printDiagnostic`: --lint mode formatter now reads
    `$d->code->value` (the enum case's backed string) for the
    `[code]` segment, fixing the runtime error from the
    DiagnosticCode migration.

Tests:

  - `AnalyzerTest::testSyntaxErrorRangeIsColumnAccurateWhenColumnInfoIsAvailable`
    — asserts exact column values (start=11, end=12) for a
    1-character syntax error in a known fixture. Locks the `- 1`
    1-based-to-0-based shift on start AND the half-open end.

  - `WorkspaceAnalyzerTest::testBoundViolationOnScalarConcrete...`
    + `testDuplicateTemplateDeclarationProducesDiagnostic`: each
    now asserts `endCharacter - startCharacter === 3` (the `Box`
    identifier is 3 chars), locking both the `+ 1` arithmetic on
    `getEndFilePos()` and the abandonment of full-line ranges.

  - `infection.json5` gains class-level ignores for the new
    defensive-guard mutations (NotIdentical, GreaterThanOrEqualTo,
    LogicalAnd, LogicalAndAllSubExprNegation, LogicalAndNegation,
    GreaterThanOrEqualToNegotiation) in `WorkspaceAnalyzer`. Same
    pattern + rationale as the existing `AstPositionResolver` guard:
    nikic's default lexer always populates startFilePos /
    endFilePos, so the >= 0 branches are unreachable from real
    source.

Smoke-tested via `--lint`:

  $x = new Box<int>();    <- column 10 (1-based) in the source
  /tmp/col.xphp:4:10: error: [xphp.bound] Generic bound violated...

  Before this commit, every diagnostic reported column 1 regardless
  of the offending position.

Verification:

  - `make test/lsp` -> OK (131 tests, 299 assertions; +1 over commit C).
  - `make test/lsp/mutation` -> 94 % MSI under the 93 % gate (dipped
    from 95 % because the new column-accurate paths add 25 mutations,
    of which several are defensive-guard equivalents now ignored
    with rationale).
  - `make test/unit` -> unchanged (171/453).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@math3usmartins

Copy link
Copy Markdown
Member Author

Review feedback addressed in this branch

A second-pass review (in review.md, gitignored) flagged 4 Significant

  • 6 Minor + 4 Nits. The four commits at the tip of the branch fold the
    fixes for everything that doesn't have its own design surface:
Review item Commit Notes
S1 -- Dead DocumentStore 7401866 Class + dir deleted; layout diagram updated.
S2 / M4 -- Stale README test counts 7401866 Three sites unified to 121 cases / 276 assertions (now 131 / 299).
S3 -- Hash collisions miscoded as xphp.bound 32c64ab New DiagnosticCode enum + triage helper distinguish BoundViolation from HashCollision by message prefix.
S4 / N3 -- Per-request reparse cost 935d47f ParsedDocumentCache keyed on TextDocumentItem::version, threaded through every handler.
M1 -- Unconstrained code strings 32c64ab Same DiagnosticCode enum; Diagnostic::$code now typed.
M2 -- Full-line diagnostic ranges 9c85422 Parse errors use nikic columns; bound + duplicate-template ranges pin to the Name's byte span.
M3 -- Broad E_DEPRECATED suppression 7401866 CAVEAT block flags that the bin-script suppression masks our own deprecations too.
N1 -- phpactor's typo (registerCapabiltiies) 7401866 One-line callout above each occurrence so the typo isn't ours.
N2 -- Defensive catch confirmation n/a Reviewer confirmed; no action needed.

The `feat/lsp` branch shipped `make test/lsp` and `make test/lsp/mutation`
locally, but CI was only running the core `make test/unit` and
`make test/mutation`. A regression in the LSP package could land on main
unnoticed.

Two new parallel jobs:

  - `phpunit-lsp` runs `make test/lsp` (121 -> 131 PHPUnit cases over the
    branch). Composer install scoped to `tools/lsp/` via ramsey/composer-
    install's `working-directory` so the LSP cache key is separate from
    the core's (a core-only change won't bust the LSP cache and vice
    versa).
  - `infection-lsp` runs `make test/lsp/mutation` after the unit job
    passes, mirroring the existing `phpunit -> infection` dependency.
    Lazy-downloads `tools/lsp/var/infection.phar` (PHAR distribution
    needed to sidestep the composer / phpactor psr/log conflict — see
    tools/lsp/README.md), enforces the same 93 % MSI gate the core uses.
    Uploads `tools/lsp/var/infection.{log,html}` as a 14-day artifact
    under the name `infection-lsp-report` (distinct from the core's
    `infection-report` to avoid the name collision).

Both LSP jobs run in parallel with their core counterparts so total CI
wall-time is governed by the slower mutation pass, not the sum.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@math3usmartins
math3usmartins merged commit 097c258 into main May 21, 2026
4 checks passed
@math3usmartins
math3usmartins deleted the feat/lsp branch May 31, 2026 19:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant