Skip to content
Open
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
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
9 changes: 9 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 @@ -41,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **A value PHP itself leaves open is `mixed`, and the diagnostic says so.** The elements of an `array` nobody described, the result of a `@return mixed` accessor, the entries of a `mixed` variadic, and a member reached through a name only known at runtime all resolve to `mixed` rather than to nothing at all, so a later `instanceof` or `is_string()` guard refines them and completion offers what the guard proved. Where nothing narrows them, a member read off one of them is still unverifiable, but the report now names `mixed` instead of saying the type could not be worked out: the first points at an annotation missing from the codebase, the second at the type engine coming up short, and reading one as the other sends you looking in the wrong place. A subject nothing typed at all still says exactly that.
- **Arithmetic on a value nobody typed is not held to both halves of its answer.** `$placeholder['position'] - 1` on an undescribed array is an `int` or a `float` depending on the value, and no annotation on the element can rule either out, so passing it to an `int` parameter was reported as a mismatch on code that is fine. Such a result is now treated the way PHP's own `/` and `**` already were: one half satisfying the target is enough. The relaxation follows the chain, so `strlen($s) + $line - 1` returned from a method declared `int` is accepted as well, and `array_sum()` over an array whose elements nobody typed answers the same way. An operand the code did describe keeps its promise: `$s * 2` on a `string` is still reported, and summing a list of floats still yields a float.
- **A `@return mixed` whose body disagrees with itself keeps the declaration.** Reading a body is how PHPantom recovers what an uninformative signature left out, but a body with several `return` statements that produce different types has not decided anything either, and the union it produces is only as complete as the branch analysis behind it. A method returning a schema, an array, or its own `mixed` argument was read as one of two classes, which claimed the array could not happen and then reported the caller that hands the result straight on. A body every `return` agrees on still narrows the declaration, so a `@return mixed` factory whose body plainly returns one class resolves to that class as before.
- **Framework resource indexing no longer appears stuck during startup.** Building reverse navigation for class names in large YAML and XML files now creates one line index per file instead of rescanning from the beginning for every match. Autoload helpers are counted only after they finish parsing, and framework resources have their own counted progress phase, so the startup bar keeps describing the work PHPantom is actually doing. Contributed by @sidux.
- **A callable's signature is completed from where it is written, not only from what it declares.** A closure passed to a sorting or mapping function left its parameters untyped, so every member read in its body went unverified: `usort($errors, fn ($a, $b) => $a->getLine() <=> $b->getLine())` now types both parameters from the array's own element type, and `uasort`/`uksort` hand their callback the values or the keys as PHP does. A callback that annotates a return type wider than what its body actually hands back keeps the narrower one, so passing the result to a parameter expecting a subclass is no longer reported as a mismatch. And a doc comment written above the statement a closure is assigned in still types the closure: PHP attaches it to the statement, but its `@param` tags belong to the closure.
- **A generic argument is recovered from wherever the call establishes it.** Three sources went unread. A `@template` that only another template's declared bound names (`@template TArray of array<T>` with `@param TArray $array`) now binds from the argument that bound the other one. A `@template` reachable through the class a `class-string` argument names is read off that class's own `@extends`/`@implements`, including through an intermediate generic interface. And the directory-walk idiom `foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir)) as $file)` binds the wrapper to the iterator it was constructed with, so the traversal yields the files it really yields. A by-reference parameter whose declared type still names the callee's own `@template` params no longer overwrites the caller's variable with an array of a name nothing can resolve.
- **A trait method inherits the `@param` of the interface method it implements.** A trait has no parent class and no interface list of its own, so a method that restates a native `string` hint lost the `@param class-string` the interface declares, and passing the parameter on to that same interface method was reported as a type mismatch. PHP flattens a trait into the classes that use it, so the declaration is now looked for where those classes provide it.
Expand All @@ -53,6 +56,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 @@ -234,6 +241,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
Loading
Loading