Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
a50af5c
docs: add Gherkin specs for cross-file resolution behavior
math3usmartins Jun 2, 2026
912e953
test: make the Gherkin specs executable with Behat (in-memory, parall…
math3usmartins Jun 2, 2026
290f727
test(behat): open fixtures into one persistent workspace
math3usmartins Jun 2, 2026
a70b2b8
test(behat): split FeatureContext into per-theme step traits
math3usmartins Jun 2, 2026
14c7043
test(navigate): go-to-definition behavior specs
math3usmartins Jun 2, 2026
bf84ca7
test(navigate): go-to-type-definition behavior spec
math3usmartins Jun 2, 2026
4f93e5e
test(navigate): find-references behavior spec
math3usmartins Jun 2, 2026
b7b4ec8
test(navigate): go-to-implementation behavior spec
math3usmartins Jun 2, 2026
2ca815c
test(navigate): document-highlight behavior spec
math3usmartins Jun 2, 2026
0053f91
test(navigate): document-symbol outline behavior spec
math3usmartins Jun 2, 2026
7349e13
test(navigate): workspace-symbol search behavior spec
math3usmartins Jun 2, 2026
eba0774
test(navigate): call-hierarchy behavior spec
math3usmartins Jun 2, 2026
fa875e0
test(navigate): type-hierarchy behavior spec
math3usmartins Jun 2, 2026
cd8063f
test(edit): rename behavior spec
math3usmartins Jun 2, 2026
ed591e9
test(edit): code-action behavior spec
math3usmartins Jun 2, 2026
83c0ada
test(edit): code-lens behavior spec
math3usmartins Jun 2, 2026
8b2b1b0
test(edit): workspace/willRenameFiles behavior spec
math3usmartins Jun 2, 2026
7c491e4
test(understand): hover behavior spec
math3usmartins Jun 2, 2026
c34eb98
test(understand): inlay-hint behavior spec
math3usmartins Jun 2, 2026
36a04ae
test(understand): signature-help behavior spec
math3usmartins Jun 2, 2026
0f58384
test(understand): folding-range behavior spec
math3usmartins Jun 2, 2026
a043332
test(understand): semantic-tokens behavior spec
math3usmartins Jun 2, 2026
d1822a5
test(validate): diagnostics behavior spec
math3usmartins Jun 2, 2026
0b6f380
test(find): completion behavior spec
math3usmartins Jun 2, 2026
7597f47
test(find): completion-item resolve behavior spec
math3usmartins Jun 2, 2026
48a2cb5
test(behat): organize features by theme; update docs and parallel target
math3usmartins Jun 2, 2026
2b06c0b
test(behat): drive the real LSP dispatcher end-to-end
math3usmartins Jun 3, 2026
ed3ef6a
test(behat): replace step traits with constructor-injected context cl…
math3usmartins Jun 3, 2026
af2b52d
test(navigate): tighten assertions to covered-text, structure, exact …
math3usmartins Jun 3, 2026
0cc4798
test(edit): tighten code-action/lens/rename assertions to payloads
math3usmartins Jun 3, 2026
9d16bac
test(understand): tighten hover/signature/inlay/folding/tokens assert…
math3usmartins Jun 3, 2026
ede1f3e
test(validate): assert diagnostic underline spans (covered text)
math3usmartins Jun 3, 2026
a37e9cd
test(find): assert completion item kind/detail and exact resolve docu…
math3usmartins Jun 3, 2026
308c333
test(navigate): add negative cases and a document-symbol Scenario Out…
math3usmartins Jun 3, 2026
b1eec28
test(edit): add negative cases (no code actions / no rename edit)
math3usmartins Jun 3, 2026
4c7dbfa
test(understand): add negative cases and a signature-help Scenario Ou…
math3usmartins Jun 3, 2026
386dd51
test(validate): add a clean-file negative (no diagnostics)
math3usmartins Jun 3, 2026
15ff7f5
test(find): add negative cases and a prefix Scenario Outline
math3usmartins Jun 3, 2026
1beab99
ci: run the Behat acceptance suite as a gate
math3usmartins Jun 3, 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
29 changes: 29 additions & 0 deletions .github/workflows/ci-lsp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,35 @@ jobs:
- name: Run PHPUnit (LSP)
run: make test/unit

behat-lsp:
name: Behat (LSP acceptance)
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

# Root deps first -- the isolated Behat install autoloads the root
# vendor/autoload at runtime (see tools/behat/composer.json).
- name: Install LSP dependencies
uses: ramsey/composer-install@v3

# Behat lives in its own composer.json (tools/behat) because Behat 3.x
# caps symfony/console at ^7 while the root pins ^8 via xphp-lang/xphp.
- name: Install Behat tooling dependencies
run: composer install --working-dir=tools/behat --no-interaction

# Drives the real LSP dispatcher end-to-end, fully in-memory; @todo
# scenarios are skipped via the gherkin tag filter in behat.dist.yml.
- name: Run Behat acceptance suite
run: make test/behat

infection-lsp:
name: Mutation testing (LSP)
# Gate: only run when explicitly requested via the Actions tab. The
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/vendor/
/var/
/tools/behat/vendor/
.phpunit.result.cache
.infection.json5.tmp.log
/docker-compose.override.yml
35 changes: 35 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,38 @@ build/phar: $(BOX_PHAR)
php -d phar.readonly=0 var/box.phar compile --no-interaction
composer install --quiet --no-interaction
@echo "==> Built $$(ls -lh var/xphp-lsp.phar | awk '{print $$5, $$9}')"

# Behat lives in an isolated tooling install (tools/behat) because Behat 3.x
# caps symfony/console at ^7 while the root project pins ^8 via xphp-lang/xphp.
# Its files-autoload pulls in the root autoloader so the harness resolves
# XPHP\Lsp\* classes. Scenarios drive the REAL language server end-to-end via
# phpactor's LanguageServerTester (the production LspDispatcherFactory + full
# middleware stack); deferred behavior is tagged @todo and skipped. This target
# is deliberately NOT part of the `test/unit` gate.
# The warmer's stderr chatter is silenced from tools/behat/bootstrap.php
# (putenv XPHP_LSP_QUIET=1), since shell env-prefixes don't propagate through
# this project's containerized `php` proxy.
# memory_limit=-1: the first scenario builds the worse-reflection stub map
# (peaks ~512M, same as the PHPUnit handler tests), which OOMs under the default
# CLI limit on a cold cache (e.g. in CI).
BEHAT_BIN := tools/behat/vendor/bin/behat
BEHAT := php -d error_reporting='E_ALL & ~E_DEPRECATED' -d memory_limit=-1 $(BEHAT_BIN)
BEHAT_FLAGS := -c behat.dist.yml --colors

$(BEHAT_BIN):
composer install --working-dir=tools/behat --quiet

.PHONY: test/behat
test/behat: $(BEHAT_BIN)
$(BEHAT) $(BEHAT_FLAGS)

# Each feature file runs in its own process. Safe because every scenario builds
# its own in-memory workspace -- no shared files/DB/ports. The one shared
# resource is the read-only PHP-stubs cache; the pre-warm run below populates it
# once (sequentially) so the parallel fan-out only ever reads it.
.PHONY: test/behat/parallel
test/behat/parallel: $(BEHAT_BIN)
@echo "==> warming shared stub cache"
@$(BEHAT) $(BEHAT_FLAGS) features/navigate/definition.feature >/dev/null 2>&1 || true
find features -name '*.feature' | xargs -P 4 -I{} \
php -d error_reporting='E_ALL & ~E_DEPRECATED' -d memory_limit=-1 $(BEHAT_BIN) $(BEHAT_FLAGS) {}
25 changes: 25 additions & 0 deletions behat.dist.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
default:
# Deferred behavior is written as @todo scenarios that document the desired
# outcome but are skipped, so the suite stays green on what's expected to work.
gherkin:
filters:
tags: '~@todo'
# Registers WorldArgumentResolver so the per-scenario World is
# constructor-injected into every context (and reset before each scenario).
extensions:
XPHP\Lsp\Test\Behat\WorldExtension: ~
suites:
default:
paths:
- '%paths.base%/features'
# Contexts live under test/ and are resolved by the root project's
# PSR-4 autoload-dev (XPHP\Lsp\Test\ -> test/), which the isolated
# Behat install pulls in via its files-autoload. ServerContext holds
# the cross-theme Givens + generic dispatchers; the rest are per-theme.
contexts:
- 'XPHP\Lsp\Test\Behat\ServerContext'
- 'XPHP\Lsp\Test\Behat\NavigateContext'
- 'XPHP\Lsp\Test\Behat\EditContext'
- 'XPHP\Lsp\Test\Behat\UnderstandContext'
- 'XPHP\Lsp\Test\Behat\ValidateContext'
- 'XPHP\Lsp\Test\Behat\FindContext'
64 changes: 64 additions & 0 deletions features/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Behavior specifications (Gherkin)

Executable acceptance specs for the xphp language server, organized by theme.
Each scenario drives the **real language server end-to-end** — phpactor's
`LanguageServerTester` builds the production `LspDispatcherFactory`, runs the
initialize/ServerCapabilities handshake, and routes real JSON-RPC requests
through the full middleware stack to the handlers. Everything is **in-memory**
(fixtures are opened via `textDocument/didOpen`; the transmitter is an array
buffer — no stdio, sockets, or files), so the suite is isolated and
parallel-safe.

```
features/
├── navigate/ definition, type-definition, references, implementation,
│ document & workspace symbols, document highlight,
│ call hierarchy, type hierarchy
├── edit/ rename, code actions, code lens, willRenameFiles
├── understand/ hover, signature help, inlay hints, folding ranges, semantic tokens
├── validate/ diagnostics (parse, undefined-name, bound, ctor-arg, duplicate)
└── find/ completion, completion-item resolve
```

Each scenario is arranged **Given** (fixture file contents + warmed FQN index) /
**When** (one LSP request) / **Then** (assert the response). Fixtures use
leading-slash URIs (`/Foo.xphp`) for handlers that go through worse-reflection.

## Step definitions

The step definitions live in `test/Behat/` as plain context classes:

- `World` — the shared per-scenario state + helpers: the `LanguageServerTester`
(real dispatcher), the request dispatch, and the fixture/position/assertion
helpers. It is **constructor-injected** into every context and a fresh one is
used per scenario/example.
- `WorldArgumentResolver` + `WorldExtension` — the Behat extension that performs
that injection (tagged `context.argument_resolver`) and resets the `World`
before each scenario/example (tagged `event_dispatcher.subscriber`).
- `ServerContext` — the cross-theme fixture Givens and the generic request
dispatchers.
- `NavigateContext`, `EditContext`, `UnderstandContext`, `ValidateContext`,
`FindContext` — the When/Then steps for each theme, delegating to the injected
`World`.

## Running

```sh
make test/behat # sequential
make test/behat/parallel # one process per feature file
```

Behat is installed in an isolated tooling dir (`tools/behat/`) because Behat 3.x
caps `symfony/console` at `^7` while the project pins `^8` via `xphp-lang/xphp`.
`make test/behat` bootstraps it on first run.

## @todo scenarios

Deferred behavior is written as `@todo` scenarios that document the desired
outcome but are skipped (via the gherkin tag filter in `behat.dist.yml`), so the
suite stays green on what's expected to work. Current `@todo`s:

- go-to-definition through a generic **method** call (navigate/definition)
- **duplicate-template** diagnostic on the edited file — the per-file pull
provider canonicalizes the edited file, so it surfaces on the other file;
needs the roadmap's cross-file diagnostic broadcast (validate/diagnostics)
48 changes: 48 additions & 0 deletions features/edit/code_action.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
Feature: Code actions
As a developer editing xphp
I want lightbulb quick-fixes for imports and diagnostics

Background:
Given the file at "/Models/User.xphp" contains the following lines:
"""
<?php
namespace App\Models;
class User {}
"""
And the file at "/Demos/Make.xphp" contains the following lines:
"""
<?php
namespace App\Demos;
function make(): void { new User(); }
"""
And the file at "/Unused.xphp" contains the following lines:
"""
<?php
namespace App;
use App\Other\Unused;
$x = 1;
"""
And the file at "/Typo.xphp" contains the following lines:
"""
<?php
$x = nul;
"""
And the FQN index has been warmed on initialize

Scenario: Offer to import an unresolved class
When I request code actions on "User" at line 2 of "/Demos/Make.xphp"
Then a code action titled "Import App\Models\User" is offered
And the "Import App\Models\User" action has kind "refactor.rewrite"
And the "Import App\Models\User" action inserts "use App\Models\User;"

Scenario: Offer to remove an unused import
When I request code actions on "Unused" at line 2 of "/Unused.xphp"
Then a code action titled "Optimize imports" is offered
And the "Optimize imports" action has kind "source.organizeImports"
And the "Optimize imports" action removes the "use App\Other\Unused;" line

Scenario: Offer to fix an undefined-name typo
When I request code actions for an undefined-name diagnostic on "nul" at line 1 of "/Typo.xphp"
Then a code action titled 'Change to "null"' is offered
And the 'Change to "null"' action has kind "quickfix"
And the 'Change to "null"' action replaces "nul" with "null"
31 changes: 31 additions & 0 deletions features/edit/code_lens.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
Feature: Code lens
As a developer editing xphp
I want a "Show references" lens above declarations, resolved lazily

Background:
Given the file at "/Foo.xphp" contains the following lines:
"""
<?php
namespace App;
class Foo {
public function bar(): void {}
}
"""
And the file at "/use.xphp" contains the following lines:
"""
<?php
use App\Foo;
$f = new Foo();
$f->bar();
"""
And the FQN index has been warmed on initialize

Scenario: Emit a "Show references" lens for a declaration
When I request code lenses for "/Foo.xphp"
Then a code lens titled "Show references" is offered

Scenario: Resolve a lens to a usage count
When I request code lenses for "/Foo.xphp"
And I resolve the first code lens
Then the resolved lens reads "2 usages"
And the resolved lens carries the reference locations
25 changes: 25 additions & 0 deletions features/edit/negative.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
Feature: Editing when there is nothing to change
As a developer editing xphp
I want no quick-fix or rename produced when there is nothing to act on

Scenario: No code actions on a clean position
Given the file at "/Clean.xphp" contains the following lines:
"""
<?php
namespace App;
$x = 1;
"""
And the FQN index has been warmed on initialize
When I request code actions on "1" at line 2 of "/Clean.xphp"
Then no code actions are offered

Scenario: Renaming a non-symbol position yields no edit
Given the file at "/R.xphp" contains the following lines:
"""
<?php
namespace App;
$x = 1;
"""
And the FQN index has been warmed on initialize
When I rename "1" at line 2 of "/R.xphp" to "Foo"
Then the response is null
26 changes: 26 additions & 0 deletions features/edit/rename.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
Feature: Rename symbol
As a developer editing xphp
I want renaming a class to rewrite its declaration and every reference

Background:
Given the file at "/User.xphp" contains the following lines:
"""
<?php
namespace App;
class User {}
"""
And the file at "/Use.xphp" contains the following lines:
"""
<?php
namespace X;
use App\User;
$u = new User();
"""
And the FQN index has been warmed on initialize

Scenario: Rename a class and all of its references
When I rename "User" at line 2 of "/User.xphp" to "Customer"
Then the rename touches 2 files
And the rename applies 3 edits
And every rename edit inserts "Customer"
And every rename edit covers "User"
24 changes: 24 additions & 0 deletions features/edit/will_rename_files.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
Feature: Rename class on file rename
As a developer editing xphp
I want renaming a file to rename its single class and update references

Background:
Given the file at "file:///Collection.xphp" contains the following lines:
"""
<?php
namespace App;
class Collection {}
"""
And the file at "file:///Consumer.xphp" contains the following lines:
"""
<?php
namespace App\X;
use App\Collection;
$c = new Collection();
"""
And the FQN index has been warmed on initialize

Scenario: Renaming the file renames the class and updates the importer
When I rename the file "file:///Collection.xphp" to "file:///Zollection.xphp"
Then the rename touches 2 files
And a willRename edit inserts "Zollection"
Loading
Loading