Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
2f78119
lsp: scaffold tools/lsp/ with analyzer + --lint mode
math3usmartins May 21, 2026
e38c83f
lsp: silence E_DEPRECATED at xphp-lsp entry-point
math3usmartins May 21, 2026
58ef08c
lsp: wire stdio LSP transport + diagnostics over phpactor/language-se…
math3usmartins May 21, 2026
3318872
lsp: implement textDocument/hover for generic instantiations + type-p…
math3usmartins May 21, 2026
74c93c6
lsp: implement textDocument/definition for generic instantiations
math3usmartins May 21, 2026
49ddf58
lsp: implement textDocument/completion inside type-arg positions
math3usmartins May 21, 2026
b6b92f0
lsp: VS Code extension client at tools/lsp/vscode-extension/
math3usmartins May 21, 2026
82d2030
docs: promote LSP MVP to Shipped on the roadmap
math3usmartins May 21, 2026
19ad2bc
docs: refresh tools/lsp/README.md to reflect the shipped MVP
math3usmartins May 21, 2026
e0875b4
docs: drop agent-os references from tools/lsp/README.md
math3usmartins May 21, 2026
edabf42
docs: explain why mutation testing is missing from tools/lsp
math3usmartins May 21, 2026
45c6810
lsp: add mutation testing under a 93 % MSI gate (lands at 95 %)
math3usmartins May 21, 2026
7401866
lsp: review feedback cleanup pass (S1, S2/M4, M3, N1)
math3usmartins May 21, 2026
32c64ab
lsp: split bound violations from hash collisions in diagnostic codes …
math3usmartins May 21, 2026
935d47f
lsp: version-keyed AST cache for handler reuse (S4, N3)
math3usmartins May 21, 2026
9c85422
lsp: column-accurate diagnostic ranges (M2)
math3usmartins May 21, 2026
3de2142
ci: run LSP unit + mutation tests in the GitHub Actions pipeline
math3usmartins May 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,31 @@ jobs:
- name: Run PHPUnit
run: make test/unit

phpunit-lsp:
name: PHPUnit (LSP)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Setup PHP 8.4
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: dom, json, mbstring, tokenizer
coverage: none
tools: composer:v2

# The LSP package has its own composer.json under tools/lsp/ with a
# path-repo pointing back at the repo root. Cache its deps separately
# from the core install so a core-only change doesn't bust the LSP cache.
- name: Install LSP dependencies
uses: ramsey/composer-install@v3
with:
working-directory: tools/lsp

- name: Run PHPUnit (LSP)
run: make test/lsp

infection:
name: Mutation testing
runs-on: ubuntu-latest
Expand Down Expand Up @@ -65,3 +90,41 @@ jobs:
var/infection.html
if-no-files-found: ignore
retention-days: 14

infection-lsp:
name: Mutation testing (LSP)
runs-on: ubuntu-latest
needs: phpunit-lsp
steps:
- uses: actions/checkout@v4

- name: Setup PHP 8.4 (with Xdebug for coverage)
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: dom, json, mbstring, tokenizer
coverage: xdebug
tools: composer:v2

- name: Install LSP dependencies
uses: ramsey/composer-install@v3
with:
working-directory: tools/lsp

- name: Run Infection (LSP)
# `make test/lsp/mutation` lazily downloads infection.phar 0.33.1
# into tools/lsp/var/ (PHAR sidesteps the composer dep conflict with
# phpactor/language-server documented in tools/lsp/README.md). Runs
# with --min-covered-msi=93; current baseline is 94%.
run: make test/lsp/mutation

- name: Upload Infection report (LSP)
if: always()
uses: actions/upload-artifact@v4
with:
name: infection-lsp-report
path: |
tools/lsp/var/infection.log
tools/lsp/var/infection.html
if-no-files-found: ignore
retention-days: 14
31 changes: 31 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,37 @@ test/unit:
test/mutation:
php vendor/bin/infection --show-mutations=max --threads=max --min-covered-msi=93

.PHONY: test/lsp
test/lsp:
cd tools/lsp && composer install --quiet && php -d error_reporting='E_ALL & ~E_DEPRECATED' vendor/bin/phpunit

# Infection runs against tools/lsp via its PHAR distribution rather than a
# composer require: phpactor/language-server pins psr/log ^1.0 which composer
# can't reconcile with infection 0.33's ^2||^3, AND sharing the root vendor's
# infection with tools/lsp would collide on thecodingmachine/safe's global
# functions (both vendor trees install it). The PHAR ships its internal deps
# under PHP-Scoper-prefixed namespaces, so neither problem applies. Downloaded
# lazily into tools/lsp/var/infection.phar (gitignored).
INFECTION_VERSION := 0.33.1
INFECTION_PHAR := tools/lsp/var/infection.phar

$(INFECTION_PHAR):
@mkdir -p $(dir $(INFECTION_PHAR))
@echo "==> Downloading infection.phar $(INFECTION_VERSION)"
@curl -fsSL -o $@ \
https://github.com/infection/infection/releases/download/$(INFECTION_VERSION)/infection.phar
@chmod +x $@

.PHONY: test/lsp/mutation
test/lsp/mutation: $(INFECTION_PHAR)
cd tools/lsp && composer install --quiet && \
php -d error_reporting='E_ALL & ~E_DEPRECATED' var/infection.phar \
--threads=max --min-covered-msi=93 --show-mutations --no-progress

.PHONY: build/lsp-extension
build/lsp-extension:
cd tools/lsp/vscode-extension && npm install --no-audit --no-fund --silent && npm run compile

.PHONY: playground
playground:
playground/bin/run
10 changes: 8 additions & 2 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,17 @@ timeline
: sha256-based generated FQCN, namespace mirrors template
: build-time hash-collision detection with copy-pasteable widen command
: XPHP_HASH_LENGTH configurable (16..64)
Tooling
: LSP server at tools/lsp/ (PHP on phpactor/language-server)
: LSP — live diagnostics (parse errors / bound violations / duplicate templates)
: LSP — hover (specialized FQN + type-param bound)
: LSP — go-to-definition for generic instantiations
: LSP — completion of class names inside type-arg positions
: VS Code extension client at tools/lsp/vscode-extension/
: --lint headless CLI for CI (file:line:col error output)
Developer experience
: PSR-4 fixtures
section Next
Tooling
: LSP / IDE integration (hover through generics, type errors before compile)
Type system depth
: default type parameters
: multiple bounds (T must satisfy A and B)
Expand Down
5 changes: 5 additions & 0 deletions tools/lsp/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/vendor/
/composer.lock
/var/
.phpunit.result.cache
.infection.json5.tmp.log
167 changes: 167 additions & 0 deletions tools/lsp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# xphp Language Server

LSP implementation that powers diagnostics, hover, go-to-definition, and completion for `.xphp`
files in VS Code (and any LSP-aware editor). Reuses the `xphp-parser` AST + `Registry` +
`TypeHierarchy` from the parent package directly — no separate parser, no duplication.

A separate Composer package living under `tools/lsp/` so it can declare its own dependencies
(notably `phpactor/language-server` for the JSON-RPC scaffolding) without weighing down the
core parser.

## Status

| Feature | Status |
|---|---|
| `--lint <file>` headless mode (parse + bound checks) | ✅ shipped |
| `textDocument/publishDiagnostics` over stdio | ✅ shipped |
| `textDocument/hover` | ✅ shipped |
| `textDocument/definition` | ✅ shipped |
| `textDocument/completion` (inside `<…>` type-arg positions) | ✅ shipped |
| VS Code extension client at `vscode-extension/` | ✅ shipped |

121 PHPUnit cases, 276 assertions — `make test/lsp`.

See `docs/roadmap.md` (Shipped → Tooling) for the broader feature inventory.

## Layout

```
tools/lsp/
├── composer.json path-references the parent xphp-parser
├── bin/xphp-lsp CLI entry — `--lint <file>` for CI, no args for LSP stdio
├── src/
│ ├── Server.php entry-point router (--lint vs LSP transport)
│ ├── LspDispatcherFactory wires phpactor middleware + DiagnosticsService + handlers
│ ├── PositionMap.php byte offset ↔ {line, char} for LSP positions
│ ├── Analyzer/
│ │ ├── Analyzer.php per-file parse + syntax-error collection
│ │ ├── WorkspaceAnalyzer cross-file Registry + TypeHierarchy + bound check
│ │ ├── Diagnostic.php framework-neutral diagnostic value object
│ │ ├── DiagnosticSeverity LSP-aligned enum
│ │ └── ParseResult.php {ast, diagnostics}
│ ├── Diagnostics/
│ │ ├── XphpDiagnosticsProvider phpactor DiagnosticsProvider impl
│ │ └── DiagnosticTranslator framework-neutral → wire-format
│ ├── Handler/
│ │ ├── AstPositionResolver find smallest Name at byte offset
│ │ ├── XphpHoverHandler textDocument/hover
│ │ ├── XphpDefinitionHandler textDocument/definition
│ │ ├── XphpCompletionHandler textDocument/completion
│ │ ├── TypeArgPositionDetector backwards-scanner for cursor-in-<…>
│ │ └── WorkspaceSymbols collect ClassLike FQNs across open docs
│ └── (phpactor's own Workspace handles document open/change/close; no
│ local DocumentStore wrapper needed)
├── test/ PHPUnit suite (121 cases)
└── vscode-extension/ VS Code client — spawns server over stdio (F5 dev loop)
```

## Install

```bash
cd tools/lsp
composer install
```

The parent `xphp-parser` package is path-referenced via composer (`repositories: type=path`),
so local edits there are picked up immediately without re-publishing.

## Run

### Lint mode (CI-friendly)

```bash
tools/lsp/bin/xphp-lsp --lint path/to/file.xphp [more.xphp ...]
```

Output format: `<file>:<line>:<col>: <severity>: [<code>] <message>` — the same shape PHPStan
and PHP itself emit, so editors and CI grep it without ceremony. Exits non-zero if any file
has diagnostics, zero otherwise.

Genuinely useful in CI today, independent of the LSP transport: run it over a `.xphp` glob to
catch bound violations and syntax errors in PRs before merge.

### LSP mode (stdio)

```bash
tools/lsp/bin/xphp-lsp # speaks LSP over stdio; no arguments
```

Use this as the `command` in any LSP client (Neovim's `vim.lsp.start`, Helix's
`languages.toml`, etc.). The bundled VS Code extension under `vscode-extension/` does this
spawn for you.

Capabilities advertised at `initialize`:

- `textDocumentSync: 1` (Full)
- `hoverProvider`
- `definitionProvider`
- `completionProvider` with `triggerCharacters: ["<", ","]`

## Test

```bash
make test/lsp # PHPUnit, 121 cases / 276 assertions
make test/lsp/mutation # Infection, 95 % MSI under a 93 % gate
```

`test/lsp` runs `composer install --quiet` then PHPUnit with
`php -d error_reporting='E_ALL & ~E_DEPRECATED'` so noisy PHP 8.4 implicit-nullable warnings
from phpactor transitive deps stay out of the output. Running phpunit directly works too —
the suppression lives in `phpunit.xml.dist`'s `<source ignoreIndirectDeprecations="true">`
block:

```bash
cd tools/lsp
vendor/bin/phpunit
```

### Mutation testing

`test/lsp/mutation` downloads `infection.phar` lazily into `tools/lsp/var/` and runs against
the same source + test set. The PHAR distribution ships its internal deps under PHP-Scoper
prefixes, so it sidesteps the `thecodingmachine/safe` / `psr/log` conflicts that prevent
composer-installed Infection from coexisting with `phpactor/language-server` (`phpactor` pins
`psr/log ^1.0` while Infection 0.33 needs `^2.0 || ^3.0`; older Infection lines that allow
`psr/log ^1.0` in turn require `symfony/console ^7` instead of the parent package's `^8`).
The PHAR avoids all of that.

Curated equivalent-mutation ignores live in `infection.json5` with per-mutator
`ignore` rules and inline rationale — mirrors the pattern at the repo root.

## VS Code extension

See `vscode-extension/README.md` for the client-side setup. Quick start:

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

## Why a separate composer package

The parser package (`xphp-lang/xphp-parser`) has a deliberately minimal dependency surface —
`nikic/php-parser` + `symfony/console`. The LSP needs `phpactor/language-server` and its
transitive deps (`amphp/`, `webmozart/`, …). Keeping the two as separate composer packages
means a downstream consumer who only wants the parser (e.g. a CI pipeline running `bin/xphp
compile`) doesn't pull in any of the LSP machinery.

The path-repo precedent for this setup is `playground/composer.json` — the layout here
follows the same pattern.

## Out-of-scope follow-ups

Each one is also documented inline at the relevant call site so a reader doing a code dive
finds the same caveat at the source. Highlights:

- **Indexer for unopened files.** Today only documents the editor has open contribute to
cross-file diagnostics + go-to-definition + completion. Walking `**/*.xphp` at `initialize`
is the obvious next pass.
- **Cross-file diagnostic broadcast.** Editing `Box.xphp` doesn't re-publish diagnostics for
every `Use.xphp` that instantiates it; the diagnostic catches up when those files are
re-touched.
- **Bound-aware completion filtering.** `Box<T: \Stringable>` still suggests non-Stringable
classes; the diagnostic catches the violation after selection.
- **Use-alias short-form completion.** `insertText` is always the full FQN today.
- **Hover/jump on bound names in template headers.** XphpSourceParser strips the `<…>` clause
so there's no AST node positioned over the bound text.
- **Marketplace publication** of the VS Code extension.
46 changes: 46 additions & 0 deletions tools/lsp/bin/xphp-lsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env php
<?php

declare(strict_types=1);

// Entry point for the xphp Language Server. Speaks LSP over stdio so any
// LSP-aware client (VS Code via the bundled extension, Neovim, Helix, etc.)
// can drive it without a transport-specific shim.
//
// The actual server boot lives in XPHP\Lsp\Server::run() — this script is
// just the thinnest possible CLI veneer plus autoloader resolution.

// Silence E_DEPRECATED at the entry-point. phpactor/language-server pulls in
// thecodingmachine/safe, which currently emits many implicit-nullable deprecation
// notices under PHP 8.4 at autoload time. Without this, the deprecation chatter
// drowns the analyzer's actual output (an LSP client sees this on stderr too).
//
// CAVEAT: this suppression is intentionally broad — it ALSO masks deprecations
// we ourselves might introduce in xphp source under tools/lsp/. The PHPUnit
// surface has a finer-grained equivalent (`<source ignoreIndirectDeprecations
// ="true">` in phpunit.xml.dist) that distinguishes by source path, but the
// runtime PHP API doesn't expose anything equivalent. If we ever start
// emitting deprecations in our own code, revisit this — the easy mitigation
// is to drop the suppression and instead pin phpactor / thecodingmachine/safe
// to versions that have caught up with PHP 8.4.
error_reporting(E_ALL & ~E_DEPRECATED);

$autoload = null;
foreach ([
__DIR__ . '/../vendor/autoload.php', // standalone install (composer install in tools/lsp/)
__DIR__ . '/../../../vendor/autoload.php', // installed as a dep of the root xphp package
] as $candidate) {
if (is_file($candidate)) {
$autoload = $candidate;
break;
}
}

if ($autoload === null) {
fwrite(STDERR, "xphp-lsp: could not locate vendor/autoload.php — run `composer install` inside tools/lsp/.\n");
exit(1);
}

require $autoload;

exit(XPHP\Lsp\Server::run($argv));
45 changes: 45 additions & 0 deletions tools/lsp/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"name": "xphp-lang/xphp-lsp",
"description": "Language Server Protocol implementation for xphp. Powers diagnostics, hover, go-to-definition and completion in VS Code (and any LSP-aware editor) by reusing the xphp-parser AST + Registry directly.",
"type": "project",
"license": "MIT",
"require": {
"php": "^8.4.0",
"phpactor/language-server": "^6.0",
"phpactor/language-server-protocol": "^3.5",
"xphp-lang/xphp-parser": "@dev"
},
"require-dev": {
"phpunit/phpunit": "^13.0"
},
"repositories": [
{
"type": "path",
"url": "../../",
"options": {
"symlink": true
}
}
],
"autoload": {
"psr-4": {
"XPHP\\Lsp\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"XPHP\\Lsp\\Test\\": "test/"
}
},
"bin": [
"bin/xphp-lsp"
],
"config": {
"platform": {
"php": "8.4.21"
},
"sort-packages": true
},
"minimum-stability": "dev",
"prefer-stable": true
}
Loading
Loading