Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ mindmap
rename
willRenameFiles
codeAction
bound-error fix-it
codeLens
Understand
hover
Expand All @@ -77,6 +78,8 @@ mindmap
duplicate-template
undefined-bareword
constructor-arg-mismatch
argument-mismatch
cross-file broadcast
Find
completion
completionItem/resolve
Expand All @@ -85,7 +88,7 @@ mindmap
stub cache
tolerant-parse
UTF-16 columns
short-name tie-break
proximity FQN resolution
lint mode
```

Expand Down
90 changes: 36 additions & 54 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,23 +41,52 @@ Features grouped by theme, not chronological or priority ordering:
```mermaid
timeline
section Shipped
Navigation: definition: typeDefinition: references: implementation: call hierarchy: type hierarchy: documentSymbol: workspaceSymbol: documentHighlight
Editing: rename: willRenameFiles: codeAction + resolve: codeLens + resolve
Understanding: hover: signatureHelp: inlayHint: foldingRange: semanticTokens
Validation: parse: bound: duplicate-template: undefined-bareword: ctor-arg-mismatch
Navigation: definition: typeDefinition: references (incl. constructor usages): implementation: call hierarchy: type hierarchy: documentSymbol: workspaceSymbol: documentHighlight (read/write)
Editing: rename: willRenameFiles: codeAction + resolve: codeLens + resolve: bound-error fix-its
Understanding: hover: signatureHelp: inlayHint: foldingRange: semanticTokens (interpolation + non-ASCII)
Validation: parse: bound: duplicate-template: undefined-bareword: ctor-arg-mismatch: arg-mismatch (method/static/function): cross-file broadcast
Completion: type-arg + member + static + variable: scope-aware insertText: completionItem/resolve
Performance: warm AST cache: stub cache: tolerant parse: UTF-16 columns: short-name tie-break: lint mode
Performance: warm AST cache: stub cache: tolerant parse: UTF-16 columns: proximity-aware FQN resolution: lint mode
section Planned
Editing: prepareRename: selectionRange
Navigation: documentLink
Validation: argument-type checker V2: cross-file broadcast
section Exploratory
Editing: bound name hover/jump: formatting: documentColor
Understanding: lowering preview: specialization explorer: instantiation inlay hints: bound-error fix-its: demangle FQN to source template
Understanding: lowering preview: specialization explorer: instantiation inlay hints: demangle FQN to source template
```

---

## Recently shipped

Moved out of Planned / Exploratory since the last revision (exercised by the
test suite; full descriptions to fold into [`README.md`](../README.md#features)):

- **Argument-type checker V2** -- a new `xphp.arg-mismatch` diagnostic extends
the constructor check to `$obj->m(...)`, `Cls::m(...)`, and `freeFn(...)`, with
conservative "simple-locals" inference for `$var` arguments assigned from a
literal / `new` earlier in the same scope.
- **Cross-file diagnostic broadcast** -- after a workspace pass, diagnostics are
re-published for every *other* open document whose results changed
(per-URI signature-guarded against edit storms), so a dependent flags / clears
without being touched.
- **Bound-error fix-its** -- a `Generic bound violated` diagnostic now offers
"Add `implements \Bound` to `<class>`" (cross-file edit on the concrete class)
and "Change type argument to `<Candidate>`" (bound-satisfying workspace types,
scalars included).
- **Proximity-aware FQN resolution** -- the filesystem index covers the whole
tree (the `test/fixture` exclusion is gone) and resolves a duplicated FQN to
the declaration nearest the requesting file; the bound-check hierarchy is
single-sourced the same way.
- **Constructor usages** -- `new X(...)` is tracked as a reference to
`X::__construct` in Find Usages, the code lens, and document-highlight.
- **Semantic tokens** -- interpolated `"… $x …"` strings split into string +
variable spans, and token lengths are UTF-16 code units (non-ASCII-correct).
- **Document highlight** -- read vs. write kind (declaration / assignment =
write, use site = read).

---

## Planned

Known shape, no open design questions. Listed in rough priority order.
Expand All @@ -76,29 +105,12 @@ cursor. PhpStorm and VS Code both bind Ctrl+W / Ctrl+Shift+W to it.
Implementation is a tree walk producing `SelectionRange { range, parent }` per
anchor.

### Argument-type checker V2 -- methods, statics, free functions (M)

`xphp.ctor-arg-mismatch` (cosntructor arguments mismatch) V1 covers `new C(...)`
and `new C<T>(...)` only. V2 extends the same diagnostic to `$obj->m(...)`,
`Cls::m(...)`, and `freeFn(...)`. The hard parts (receiver-type resolution,
substitution through generics) already work for hover / signature help; the V2
checker reuses them and emits the same diagnostic shape as V1.

### `documentLink` -- clickable URLs in comments (S)

`textDocument/documentLink` returns ranges + URIs for URLs and PSR-4-style
references inside comments / docblocks. Editors underline them and `Cmd+Click`
opens. Low value compared to the above, listed for completeness.

### Cross-file diagnostic broadcast (M)

Today: editing `Box.xphp` re-publishes `Box.xphp`'s diagnostics only; `Use.xphp`
(which instantiates `Box<X>`) catches up the next time it's touched. The fix is
straightforward -- after each diagnostic pass, also re-publish for every file
whose AST cites the changed file's templates. The remaining work is the right
batching window so a rapid edit storm doesn't flood every consumer on every
keystroke.

---

## Exploratory
Expand Down Expand Up @@ -200,36 +212,6 @@ IntelliJ; Rust's `rustc-demangle` for symbol names.
method `xphp.demangle`. Prototype consumption in PhpStorm's
"Analyze stacktrace" dialog as a transformation pass.

### Bound-error fix-its -- "implement missing interface" / "swap type-arg"

**What it'd do.** Today, a `Generic bound violated` diagnostic shows
the explanation but no quick fix. The fix-it would offer:

1. "Add `implements \Stringable` to `class App\Models\User`" -- one
text edit on the supplied concrete type's declaration.
2. "Swap type-arg to `<Tag>`" -- show the list of project classes
that already satisfy the bound, picked via the existing bound-
aware completion filter.

**Open questions.**

- For (1), insertion of `implements` is a straightforward parser
walk; the leading `\` qualification is the same problem
`ClassNameImportContext` solves and can be reused.
- For (2), candidate ranking: alphabetical, by frequency in the
project, or by current import status?
- Cross-file edits: the type-arg site and the class declaration
may live in different files. LSP `WorkspaceEdit` handles this
in the protocol, but the UI feedback in PhpStorm for multi-file
actions is uneven.

**Prior art.** TypeScript LSP's "Add missing properties" quick
fix; Rust analyzer's "Implement trait" assist.

**Initial step.** Fix (2) wired end-to-end first (single-file
edit, reuses existing completion infrastructure). Fix (1) deferred
until the multi-file edit story is ironed out via the rename loop.

### Hover / jump on bound names in template headers

**What it'd do.** Cursor on `Stringable` inside
Expand Down
54 changes: 45 additions & 9 deletions features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@ features/
├── navigate/ definition, type-definition, references, implementation,
│ document & workspace symbols, document highlight,
│ call hierarchy, type hierarchy
├── edit/ rename, code actions, code lens, willRenameFiles
├── edit/ rename, code actions (import, optimize, bound fix-its),
│ code lens, willRenameFiles
├── understand/ hover, signature help, inlay hints, folding ranges, semantic tokens
├── validate/ diagnostics (parse, undefined-name, bound, ctor-arg, duplicate)
├── validate/ diagnostics (parse, undefined-name, bound, duplicate,
│ argument-type mismatch: ctor / method / static / function),
│ cross-file broadcast (push-mode re-publish of dependents)
└── find/ completion, completion-item resolve
```

Expand Down Expand Up @@ -52,13 +55,46 @@ 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.

## Coverage boundary — what is *not* covered here, and why

The suite is **100% in-memory**: `World` builds the server with
`new InitializeParams(new ClientCapabilities())` — **no `rootUri`/`rootPath`** —
so the filesystem walk is empty and every scenario is open-document-only (files
arrive via `textDocument/didOpen`, never from disk). That guarantee is what
keeps the suite isolated and parallel-safe, but it puts whole categories of
behavior structurally out of reach. Those are covered by **PHPUnit unit tests**
instead (named below), not Behat — driving them here would require writing real
files to a real `rootUri`, which would break the in-memory / no-disk /
parallel-safe invariant.

**Filesystem layer (unit-tested, never Behat):**
- The FQN **filesystem index** + **proximity-aware resolution** and its
per-request origin anchor (`OriginTrackingMiddleware`) — duplicate FQNs across
on-disk files resolved by nearness to the requesting document.
→ `test/Reflection/FqnIndexTest.php`, `test/Dispatcher/OriginTrackingMiddlewareTest.php`
- Cross-file go-to-definition / hover into **closed** (on-disk, not open) files.
- The warmers (`FqnIndexWarmer`, `ParsedDocumentCacheWarmer`).
- Bound-check hierarchy single-sourcing across duplicate-FQN packages.
→ `test/Diagnostics/XphpDiagnosticsProviderTest.php`
- `workspace/didChangeWatchedFiles` (file-watcher index invalidation).

**Other unit-only behaviors:**
- Non-ASCII semantic-token **length** (UTF-16 code units): the Behat token
decoder is byte-based, so the length is pinned in
`test/Handler/SemanticTokens/AstVisitorTest.php` instead.

**In-memory-drivable but currently not scripted (low value):**
- Document-lifecycle notifications `textDocument/didClose` / `didSave` /
`willSave` / `willSaveWaitUntil` (Behat only drives `didOpen` / `didChange`).
- The `codeAction/resolve` round-trip (providers attach edits eagerly, so the
resolve step is a no-op in practice).

Everything else — all of navigate / edit / understand / validate / find — is
exercised end-to-end through the real dispatcher here.

## @todo scenarios

Deferred behavior is written as `@todo` scenarios that document the desired
Deferred behavior can be 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)
suite stays green on what's expected to work. There are currently none — every
scenario runs.
50 changes: 50 additions & 0 deletions features/edit/bound_fixes.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
Feature: Quick-fixes for generic bound violations
As a developer editing xphp
I want lightbulb fixes when a type argument violates a generic bound

Background:
Given the file at "/Stringy.xphp" contains the following lines:
"""
<?php
namespace App;
final class Stringy implements \Stringable {
public function __toString(): string { return ''; }
}
"""
And the file at "/Box.xphp" contains the following lines:
"""
<?php
namespace App;
class Box<T: \Stringable> { public T $item; }
"""
And the FQN index has been warmed on initialize

Scenario: Offer to swap a scalar type argument for a bound-satisfying type
Given the file at "/Use.xphp" contains the following lines:
"""
<?php
namespace App;
$x = new Box<int>();
"""
When I request code actions for the "xphp.bound" diagnostic in "/Use.xphp"
Then a code action titled "Change type argument to Stringy" is offered
And the "Change type argument to Stringy" action has kind "quickfix"
And the "Change type argument to Stringy" action replaces "int" with "Stringy"

Scenario: Offer to implement the bound on the offending concrete class
Given the file at "/Money.xphp" contains the following lines:
"""
<?php
namespace App;
class Money {}
"""
And the file at "/Use.xphp" contains the following lines:
"""
<?php
namespace App;
$x = new Box<Money>();
"""
When I request code actions for the "xphp.bound" diagnostic in "/Use.xphp"
Then a code action titled "Add implements \Stringable to Money" is offered
And the "Add implements \Stringable to Money" action inserts "implements \Stringable"
And a code action titled "Change type argument to Stringy" is offered
20 changes: 20 additions & 0 deletions features/edit/code_lens.feature
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,23 @@ Feature: Code lens
And I resolve the first code lens
Then the resolved lens reads "2 usages"
And the resolved lens carries the reference locations

Scenario: A constructor lens counts "new" instantiation sites
Given the file at "/Gadget.xphp" contains the following lines:
"""
<?php
namespace App;
class Gadget {
public function __construct() {}
}
"""
And the file at "/GadgetUse.xphp" contains the following lines:
"""
<?php
namespace App;
$a = new Gadget();
$b = new Gadget();
"""
When I request code lenses for "/Gadget.xphp"
And I resolve the code lens on line 3
Then the resolved lens reads "2 usages"
1 change: 0 additions & 1 deletion features/navigate/definition.feature
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ Feature: Go to definition
Then the response points to "Models/User.xphp"
And the target range covers the "User" class name

@todo
Scenario: Jump through a generic method call to its declaration
When I request "textDocument/definition" on "first" at line 10 of "Use.xphp"
Then the response points to "Containers/Collection.xphp"
Expand Down
5 changes: 5 additions & 0 deletions features/navigate/document_highlight.feature
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,8 @@ Feature: Document highlight
When I request "textDocument/documentHighlight" on "User" at line 2 of "/Use.xphp"
Then the response contains 3 highlights
And each highlight covers "User" in "/Use.xphp"

Scenario: Classify the declaration as a write and the uses as reads
When I request "textDocument/documentHighlight" on "User" at line 2 of "/Use.xphp"
Then a "write" highlight covers "User" in "/Use.xphp"
And a "read" highlight covers "User" in "/Use.xphp"
20 changes: 20 additions & 0 deletions features/navigate/references.feature
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,23 @@ Feature: Find references
And a reference in "/Use1.xphp" covers "App\User"
And a reference in "/Use1.xphp" covers "User"
And a reference in "/Use2.xphp" covers "\App\User"

Scenario: Find usages of a constructor includes "new" instantiation sites
Given the file at "/Widget.xphp" contains the following lines:
"""
<?php
namespace App;
class Widget {
public function __construct(public string $id) {}
}
"""
And the file at "/WidgetUse.xphp" contains the following lines:
"""
<?php
namespace App;
$a = new Widget('a');
$b = new Widget('b');
"""
When I request "textDocument/references" on "__construct" at line 3 of "/Widget.xphp"
Then the response contains 3 locations
And a reference in "/WidgetUse.xphp" covers "Widget"
13 changes: 13 additions & 0 deletions features/understand/semantic_tokens.feature
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,16 @@ Feature: Semantic tokens
When I request "textDocument/semanticTokens/full" for "/box.xphp"
Then the semantic tokens are non-empty
And a "typeParameter" token covers "T" in "/box.xphp"

Scenario: Highlight an interpolated variable inside a double-quoted string
Given the file at "/Str.xphp" contains the following lines:
"""
<?php
namespace App;
$name = 'Ada';
$greeting = "Hello $name world";
"""
And the FQN index has been warmed on initialize
When I request "textDocument/semanticTokens/full" for "/Str.xphp"
Then a "variable" token covers "$name" in "/Str.xphp"
And a "string" token covers "Hello " in "/Str.xphp"
Loading
Loading