Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
92e0dc1
feat(php): Add scalable reference CodeLens
sidux Aug 26, 2026
0ccf3f5
fix(indexing): Reuse workspace index for annotations
sidux Aug 27, 2026
be8c516
fix(indexing): Batch member reference counts
sidux Aug 27, 2026
4176e9e
fix(indexing): Reuse forward scopes for member counts
sidux Aug 27, 2026
5757191
fix(indexing): Coalesce member count refreshes
sidux Aug 27, 2026
e1daa32
fix(indexing): Cache semantic member targets
sidux Aug 27, 2026
f4bea42
fix(references): keep member batching framework-neutral
sidux Aug 27, 2026
d06fdfe
feat(navigation): Navigate PHP symbols in YAML and XML
sidux Aug 25, 2026
66ef462
feat(navigation): Index YAML and XML PHP references
sidux Aug 26, 2026
dc6652f
feat(frameworks): Add Symfony and Doctrine resource navigation
sidux Jul 6, 2026
63cd4fa
fix(symfony): Preserve framework CodeLens destinations
sidux Aug 5, 2026
1f54594
fix(doctrine): Bound reverse relationship CodeLens
sidux Aug 27, 2026
5354811
fix(navigation): Preserve Symfony resource fallbacks
sidux Aug 27, 2026
7c90214
fix(doctrine): Cache repository mapping index
sidux Aug 27, 2026
2c60084
fix(indexing): Index framework reference lookups
sidux Aug 27, 2026
6106c78
test(frameworks): avoid redundant URI allocations
sidux Aug 27, 2026
af3df00
feat(symfony): Index PHP configuration resources
sidux Jul 29, 2026
ff40130
feat(symfony): Add service container intelligence
sidux Jul 29, 2026
fd29607
fix(symfony): Handle Unicode in PHP resource scans
sidux Jul 29, 2026
086d08a
feat(symfony): Add route intelligence
sidux Jul 29, 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
9 changes: 6 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ src/
├── composer.rs # composer.json / PSR-4 autoload parsing
├── names.rs # Name resolution (FQN, use-map, namespace)
├── reference_index.rs # Workspace-wide reference index for find-references / rename
├── reference_counts.rs # Background-computed member reference counts for the declaration inlay hints
├── reference_counts.rs # Bounded exact member-reference cache for declaration hints and lenses
│ # Class & type resolution
├── resolution.rs # Multi-phase class/function lookup across files (find_or_load_class)
Expand Down Expand Up @@ -98,6 +98,7 @@ src/
│ # LSP features (one module each)
├── hover/ # Hover: symbol-map dispatch, type/signature/docblock formatting
├── definition/ # Go-to-definition (resolve, member, variable/, implementation, type_definition)
├── resource_navigation.rs # Schema-free PHP class/member indexing and navigation in YAML/XML
├── references/, rename/, highlight/
├── signature_help.rs, semantic_tokens.rs, inlay_hints.rs, folding.rs, code_lens.rs
├── document_symbols.rs, document_links.rs, workspace_symbols.rs, formatting.rs
Expand Down Expand Up @@ -877,12 +878,14 @@ When the user invokes "Find All References", PHPantom scans all user files for o
Before scanning, `ensure_workspace_indexed` ensures all user files have symbol maps:

1. **Phase 1: fqn_uri_index files (user only)** — files already known from `update_ast` calls. Vendor and stub URIs are skipped.
2. **Phase 2: `.gitignore`-aware workspace walk** — uses the `ignore` crate's `WalkBuilder` to recursively discover PHP files under the workspace root, respecting `.gitignore` rules (including nested and global gitignore files). This automatically skips generated/cached directories like `storage/framework/views/` (Laravel blade cache), `var/cache/` (Symfony), and `node_modules/`. The vendor directory is always skipped regardless of `.gitignore` content. Hidden directories are skipped by default.
2. **Phase 2: `.gitignore`-aware workspace walk** — uses the `ignore` crate's `WalkBuilder` to recursively discover PHP plus YAML/XML resource files under the workspace root, respecting `.gitignore` rules (including nested and global gitignore files). This automatically skips generated/cached directories like `storage/framework/views/` (Laravel blade cache), `var/cache/` (Symfony), and `node_modules/`. The vendor directory is always skipped regardless of `.gitignore` content. Hidden directories are skipped by default.

Both phases parse files in parallel using `std::thread::scope`. The work is split into chunks (one per CPU core) and each thread reads a file from disk and calls `update_ast`, which acquires write locks briefly to store results while the expensive parsing step runs without any locks held. Batches of 2 or fewer files skip threading overhead.
PHP files are parsed in parallel using `std::thread::scope`. The work is split into chunks (one per CPU core) and each thread reads a file from disk and calls `update_ast`, which acquires write locks briefly to store results while the expensive parsing step runs without any locks held. Batches of 2 or fewer files skip threading overhead. YAML/XML files take the lightweight schema-free scanner and publish synthetic class/member symbol maps into the same reference index.

Parsed files stay cached in `uri_classes_index`, `symbol_maps`, `file_imports`, and `file_namespaces` after the scan completes. There is no post-scan eviction; keeping the entries means subsequent operations (a second find-references call, go-to-definition on a cross-file symbol) benefit from the work already done.

The workspace reference index keeps its primary map deliberately coarse: it stores candidate URIs and occurrence counts, not a second copy of every source position. CodeLens can therefore answer a conclusive zero without a semantic scan. The first nonzero member query resolves every member receiver in each candidate file while one forward-walked variable scope is active, packs the target class atoms by symbol-span index, and retains that compact per-file semantic layer for later member names. Candidate files are filled in parallel; edits evict their own layer, and signature changes clear layers whose receiver types may have changed. Exact locations remain bounded behind the 50,000-location annotation cache. Refresh-capable clients receive the lens after the background result is ready; other clients retain lazy `codeLens/resolve` as a compatibility path.

### Cross-file scanning

The `user_file_symbol_maps()` helper snapshots all symbol maps whose URI does not fall under the vendor directory or the internal stub scheme. With `Arc<SymbolMap>`, the snapshot is a vector of cheap reference-count increments rather than deep clones. Four scanners use this snapshot:
Expand Down
15 changes: 15 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Fully-qualified PHP classes navigate from YAML and XML.** Ctrl+Click a class name in any YAML key or value, or any XML attribute or text node, and PHPantom opens its PHP declaration without needing to know that file's schema. `Class::member` references navigate too. The same occurrences feed Find References and declaration CodeLens through the workspace reference index. Unknown and unqualified strings are left alone. Contributed by @sidux.
- **Reference CodeLens.** PHP declarations show clickable exact reference counts. Declarations with no indexed uses are answered immediately, while semantic member locations are cached in a bounded background index so opening a large file does not fan out into an expensive resolve request per lens. Clients that support CodeLens refresh receive only ready, fully resolved member lenses. Contributed by @sidux.
- **`analyze` takes more than one path.** `phpantom_lsp analyze app/ lib/Helper.php tests/` scans the union of everything named, mixing directories and single files freely, so a pre-commit hook or a CI step can hand it exactly the paths that changed instead of running the whole project or invoking the binary once per path. Overlapping arguments are reported once, and a path that does not exist still stops the run with exit code 2. Naming no path scans the entire project, as before.

### Changed
Expand Down Expand Up @@ -53,6 +55,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`@phpstan-assert` narrows a property of the receiver, not just an argument.** A tag naming a path through `$this` (`@phpstan-assert bool $this->resolved`) was ignored, because the subject was looked for among the call's arguments and such a call often has none. The lazy-initialiser idiom (`if ($this->resolved === null) { $this->resolve(); } return $this->resolved;`) therefore reported the nullable property as the return value. The tag's `$this` now stands for whatever the call was made on, so it narrows through a variable receiver as well.
- **A class named `Scalar` or `Numeric` is a class, not a PHPDoc pseudo-type.** `scalar` and `numeric` have no native spelling in PHP, so a project may name a class either of them, and nikic/php-parser does exactly that with `PhpParser\Node\Scalar`. Any capitalised spelling was folded into the pseudo-type instead of being resolved through the file's imports, which left the name unqualified and every check against it failing: passing a `Scalar` to a parameter typed as its own parent was reported as a mismatch, in a native type hint, a `@param`, a `@return`, and a `@implements` type argument alike. The all-lowercase spellings keep their PHPDoc meaning, which is the same rule already applied to `Number`, `Integer`, `Boolean`, `Double`, and `Resource`.
- **An `&&` operand that pins a value to one class outranks a later operand that only lists alternatives.** `$bound instanceof GenericType && ($class === GenericType::class || $bound instanceof TemplateType)` read the two operands as peers and answered `GenericType|TemplateType`, so passing the value on to anything expecting a `GenericType` was reported as a mismatch — even though the first operand alone settles the question. The disjunction can only narrow the value further, never widen it past what was already proven. A disjunction still narrows on its own when nothing in the chain pinned the subject down.
- **Reference CodeLens stays responsive in large projects.** PHP member receivers are resolved once per file in parallel and kept in a compact semantic index, so later lens batches filter exact references without reopening or walking source files. The worker drains a request burst before sending one editor refresh, and clients without lens refresh compute only the lens they resolve. Contributed by @sidux.
- **Reference CodeLens stays responsive when a project has many framework resources or repeated member accesses.** Symfony and Doctrine class/member links now use an incremental inverted index instead of scanning every YAML/XML reference for each PHP declaration. Exact member searches also reuse one parsed PHP syntax tree per candidate file, avoiding repeated reparses for common methods. Contributed by @sidux.
- **Reference and CodeLens annotations reuse the completed workspace index.** Resolving many lenses in a large project no longer starts another full filesystem walk for every declaration. Concurrent annotations share the first indexing pass, while an explicit Find References command still refreshes once so files created without an editor notification remain discoverable. Contributed by @sidux.
- **Doctrine relationship CodeLens stays bounded on large workspaces.** Entity-to-repository pairs are indexed as mapping resources change instead of rescanning every YAML/XML file per lens. Reverse repository lenses use those mappings directly and apply the standard naming convention without repeatedly resolving repository candidates for every indexed class. Contributed by @sidux.
- **Argument checks accept the widenings PHP performs and the types the engine admits it does not know.** Four shapes of correct code were reported as type mismatches: a bounded `int<0, max>` passed to a `float` parameter, even though PHP widens an integer to a float on the way in; a `class-string` passed to `non-empty-string`, even though a string that names a class always has content; an `array-key` passed to `int` or to `string`, which is the key type of an array nobody described rather than a value measured to be two things; and a closure body doing `$a & $b` on untyped parameters, which produces a string from two strings just as readily as an int from two numbers.
- **`get_class($x) === Foo::class` narrows the same subjects `instanceof` does.** The identity check only pinned a plain variable, so `get_class($this->held) === Sub::class` and `get_class($items[0]) === Sub::class` left the subject at its declared type and every member read past the check was reported as missing. A property fetch, an array element, and a call result are all narrowed now, in the `$x::class === Foo::class` spelling as well.
- **A global function written with a leading backslash is the same function.** `\get_class($x) === Foo::class` and `\is_a($x, Foo::class)` narrowed nothing, and `if (!\class_exists('Vendor\Optional\Config')) { return; }` read as an un-negated guard, so it protected the `return;` instead of everything after it and the guarded class was reported as not found. A class named in such a guard with escaped backslashes (`'Vendor\\Optional\\Config'`) is now matched against the reference it guards, too.
Expand Down Expand Up @@ -166,6 +172,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Laravel and Carbon macros registered with `mixin()`.** A `Str::mixin(new StrMixin())` or `Collection::mixin(CollectionMixin::class)` call contributes one macro per public method of the mixin, taking the signature of the closure that method returns, so those methods complete, hover, resolve, and type-check. Carbon's trait-based `mixin()` is read the way Carbon reads it, where the trait's methods become methods on the target directly. Contributed by @shuvroroy (#256) and @calebdw.
- **Larastan's `model-property<Model>` is checked and completed.** The pseudo-type is resolved against the model's known properties during argument checking, so a string literal that names no property is flagged, and typing inside such an argument completes the model's property names. Contributed by @calebdw.

#### Symfony and Doctrine

- **Symfony route intelligence.** Route names and path parameters declared with attributes or in YAML, XML, and PHP now complete, navigate, find references, rename, highlight, and show declaration-side code lenses across controllers and Twig templates. Project-local missing route names produce diagnostics. Contributed by @sidux.
- **Symfony service container intelligence.** Service IDs and parameters declared in YAML, XML, and PHP configuration now complete, navigate, find references, rename, highlight, and show declaration-side code lenses across configuration and PHP usage sites. Project-local missing IDs and parameters produce diagnostics. Contributed by @sidux.
- **Symfony and Doctrine configuration navigation.** Service declarations, route controllers, and Doctrine mappings in YAML, XML, and Symfony PHP configurators now participate in go-to-definition, find references, rename, document highlights, and PHP code lenses. Namespace and resource-path refactors also update matching framework configuration. Contributed by @sidux.

#### Diagnostics

- **Two new diagnostics: illegal `readonly` writes and self-contradicting docblocks.** A write to a `readonly` property from anywhere PHP forbids one, and a `@param` or `@return` tag that contradicts the nullability of the declaration it documents, are now reported where you write them rather than when the code runs. Every form the readonly write can take is checked, including the ones that are easy to overlook (`unset()`, a `foreach` or destructuring target, taking a reference), and the writes the language allows are left alone.
Expand Down Expand Up @@ -234,6 +246,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Code lenses with several targets keep every destination.** Symfony and Doctrine annotations that point to more than one matching declaration now pass the full location list to the editor instead of opening only the first. Contributed by @sidux.
- **Whitespace-only PHP strings no longer crash Symfony indexing.** A string containing only spaces could produce an invalid source range while PHPantom scanned Symfony-aware PHP files, terminating the request instead of ignoring the empty value. Contributed by @sidux.
- **The deprecation pass parses a file once, not once per subject.** Deprecation checking recently became precise about which member accesses can share a resolved type, at the cost of resolving many more distinct subjects per file. Each of those resolutions re-parsed the file from scratch, and every `$this` inside a closure body paid for a fresh parse of its own, so diagnostics on a file with many closures ran several times slower than before. The pass now reuses one parsed AST and one chain-resolution cache across the whole file, the same way the unknown-member pass always has, making it faster than it was before the precision fix.
- **Go-to-definition on a Blade echo delimiter agrees with its hover.** `{{ }}` compiles to a call to `e()`, which is not written anywhere in the template, and hovering the `{{`/`}}` itself already reflected that by describing the implicit `e()` call. Ctrl+Click on the same character disagreed: it fell through to the underlying PHP expression and landed on whatever the delimiter happened to sit next to, such as `route(...)` in `{{ route('pages.index') }}`. It now targets `e()` too.
- **A method chain no longer resolves against another file's `use` import.** The cache that reuses a shared chain prefix (`Pen::make()` in `Pen::make()->write()`) keyed its entries by the chain's text alone, with nothing to tell two files apart. A background scan that walks many files under one cache activation, such as Find References or the reference-count computation behind the inlay hints, could resolve a chain in one file against a same-named class a different file imports under the same alias (`use A\Pen;` in one, `use B\Pen;` in another), undercounting or overcounting references depending on which file the cache was populated from first. Each file's chains are now cached separately.
Expand Down Expand Up @@ -564,6 +578,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Docblock navigation lands on the right name in types that mix a `*` wildcard with a non-ASCII name.** In a type such as `@return Map<Café, *, User>`, go-to-definition, find references, and rename measured every name after the accented one against the wrong bytes, so clicking `User` resolved nothing (or the wrong symbol). The PHPStan `*` wildcard is now read directly by the type grammar rather than rewritten to `mixed` beforehand, which removes the byte-offset bookkeeping that was corrupting the positions.
- **Formatting a short method chain starting with `new X(...)` no longer breaks it across lines unnecessarily.** When the constructor call's own arguments were long enough to wrap, the formatter also forced a short trailing chain like `(new Foo(...))->bar()` onto separate lines even though it would have fit on one. Upstream fix from mago 1.44.0.
- **`@method` and `@property` tags on an implemented interface are now always applied.** A class that declared no docblock of its own missed the magic methods and properties its interfaces declared, so they did not complete, hover, or resolve, and calls to them were reported as unknown members. Tags on an interface (and on the interfaces it extends) are now picked up regardless of what the implementing class documents.
- **Symfony PHP resource scanning handles non-ASCII source safely.** Indexing framework references no longer crashes when a multibyte character falls near the bounded call-context scan window. Contributed by @sidux.
- **Find References, Rename, and Go to Implementation no longer look stalled during startup indexing.** A search started while the background index is still parsing the workspace waits for that index to finish, since acting on a partial index would silently miss results. That wait now shows in the request's own progress bar as "Waiting for workspace index" alongside the index's live file counts, instead of sitting at "Resolving…" with no indication of what it is waiting for.
- **Type narrowing against `@phpstan-assert`/`@psalm-assert` no longer leaks memory.** Evaluating a narrowing call such as `Assert::isInstanceOf($x, Foo::class)` or a custom function/method with the same annotations allocated a small amount of memory that was never freed. This ran on every conditional touched during completion, hover, diagnostics, and go-to-definition, so memory held by a long-running editor session grew slowly but permanently the more the project was edited. Fixed by no longer leaking the allocation.
- **Renaming a namespace no longer corrupts group `use` statements.** Renaming a namespace segment that is imported with a group `use` (e.g. `use App\Old\{Foo, Bar};`) previously rewrote the group's shared prefix and then also spliced the new prefix into each member name, producing invalid PHP like `use App\New\{App\New\Foo, Bar};`. The member names are left untouched now, since the prefix rewrite alone already updates the whole statement correctly.
Expand Down
Loading
Loading