Skip to content

Commit c1fc03f

Browse files
committed
Prevent memory growth from file scanning
1 parent cee1e1b commit c1fc03f

9 files changed

Lines changed: 306 additions & 126 deletions

File tree

docs/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2828

2929
- **Elseif chain narrowing.** In `if/elseif/else` chains, the else branch now strips types from all preceding conditions. For example, `if (is_string($x)) {} elseif (is_int($x)) {} else { $x-> }` correctly narrows `$x` by excluding both `string` and `int`.
3030
- **Variadic `@param` template bindings.** `@param class-string<T> ...$items` now correctly binds the template parameter. Previously the `...` prefix prevented the parameter name from being recognized, so generic return types were not substituted.
31+
- **Laravel relationship classification.** Relationship return types that are fully-qualified to a non-Eloquent namespace (e.g. a custom `App\Relations\HasMany`) are no longer misclassified as Eloquent relationships. Only types under `Illuminate\Database\Eloquent\Relations\` or unqualified short names are recognized.
32+
- **Memory usage after go-to-implementation and find references.** Files transiently loaded during implementation scanning and workspace indexing are now evicted from the AST cache when they are not open in the editor. Previously these entries accumulated and increased memory usage over time.
3133
- **`@phpstan-type` aliases in foreach.** Type aliases defined via `@phpstan-type` or `@psalm-type` now resolve correctly when the aliased type is iterated in a `foreach` loop, destructured with `list()`/`[]`, or used as a foreach key type.
3234
- **Mixed `->` then `::` accessor chains.** Expressions like `$obj->prop::$staticProp` and `$obj->method()::staticMethod()` now resolve through the full chain instead of losing the instance prefix.
3335
- **Inline `(new Foo)->method()` chaining.** Parenthesized `new` expressions used as the root of a method chain now resolve for completion. Previously only assigned `new` expressions (`$x = new Foo()`) worked.

docs/todo.md

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -188,12 +188,6 @@ eventually but don't move the needle.
188188
| 72 | Switch → match conversion | Medium | Code Actions | [actions.md §4](todo/actions.md#4-switch--match-conversion) |
189189
| 73 | Incremental text sync | Medium | LSP Features | [lsp-features.md §17](todo/lsp-features.md#17-incremental-text-sync) |
190190

191-
### Bug fixes
192-
193-
| # | Item | Effort | Domain | Doc Link |
194-
|---|---|---|---|---|
195-
| 74 | Short-name collisions in `find_implementors` | Low | Bug Fixes | [bugs.md §1](todo/bugs.md#1-short-name-collisions-in-find_implementors) |
196-
| 75 | Evict transiently-loaded files from ast_map after GTI and Find References | Low | LSP Features | [bugs.md §13](todo/bugs.md#13-evict-transiently-loaded-files-from-ast_map-after-gti-and-find-references) |
197191

198192
---
199193

@@ -216,7 +210,6 @@ separately by their own impact÷effort scoring.
216210
| L10 | `SoftDeletes` trait methods on Builder ||| [§10](todo/laravel.md#10-softdeletes-trait-methods-on-builder) |
217211
| L11 | `View::withX()` / `RedirectResponse::withX()` dynamic methods || ★★ | [§11](todo/laravel.md#11-viewwithx-and-redirectresponsewithx-dynamic-methods) |
218212
| L12 | `$appends` array ||| [§12](todo/laravel.md#12-appends-array) |
219-
| L13 | Relationship classification matches short name only || ★★ | [§13](todo/laravel.md#13-relationship-classification-matches-short-name-only) |
220213

221214
---
222215

docs/todo/bugs.md

Lines changed: 37 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -26,60 +26,32 @@ fallback is only used when FQN information is absent. `seen_fqns` in
2626
---
2727

2828
## 2. GTD fires on parameter variable names and class declaration names
29-
**Impact: Medium · Effort: Low**
30-
31-
Go-to-definition fires on parameter variable names (`$supplier`, `$country`)
32-
and class declaration names (`class Foo`), navigating to the same location —
33-
the cursor is already at the definition. This is noisy and unexpected:
34-
clicking a parameter name or a class declaration name should either do
35-
nothing or offer a different action (e.g. find references).
36-
37-
### Current behaviour
38-
39-
- **Parameter names:** Ctrl+Click on `$supplier` in a method signature
40-
jumps to… `$supplier` in the same method signature. The `VarDefSite`
41-
with `kind: Parameter` is correctly recorded, and `find_var_definition`
42-
returns it — so the "definition" is the cursor's own position.
43-
44-
- **Class declarations:** Ctrl+Click on `Foo` in `class Foo {` jumps to
45-
the same `Foo` token. The `SymbolMap` records a `ClassDeclaration`
46-
span, and `resolve_definition` resolves it to the same file and offset.
47-
48-
### Fix
49-
50-
In the definition handler, after resolving the definition location, check
51-
whether the target location is the same as (or within a few bytes of) the
52-
cursor position. If so, return `None` — there is no useful jump to make.
29+
**Impact: Medium · Effort: Low (fixed)**
5330

54-
Alternatively, suppress at the `SymbolKind` level:
55-
- For `Variable` spans where `var_def_kind_at` returns `Some(Parameter)`,
56-
skip definition.
57-
- For `ClassDeclaration` spans, skip definition.
31+
**Status:** Fixed. Three layers suppress self-referential jumps:
5832

59-
### Tests to update
33+
1. `resolve_from_symbol` returns `None` for `ClassDeclaration` and
34+
`MemberDeclaration` symbol kinds (the cursor is at the definition).
35+
2. `lookup_var_def_kind_at` detects when the cursor is on a variable
36+
at its definition site (parameter, assignment LHS, foreach binding,
37+
catch binding) and returns `None` before `find_var_definition` runs.
38+
3. A self-reference guard in `resolve_definition` suppresses jumps
39+
when the resolved location points back to the cursor position.
6040

61-
Several existing definition tests assert that parameter names and class
62-
declarations produce a definition result pointing to themselves. These should
63-
expect `None` instead.
41+
Tested with `test_goto_definition_parameter_at_definition_returns_none`
42+
and related tests in `definition_variables.rs`.
6443

6544
---
6645

6746
## 3. Relationship classification matches short name only
68-
**Impact: Low · Effort: Low**
69-
70-
`classify_relationship` in `virtual_members/laravel.rs` strips the
71-
return type down to its short name (via `short_name`) and matches
72-
against a hardcoded list (`HasMany`, `BelongsTo`, etc.). This means
73-
any class whose short name collides with a Laravel relationship class
74-
(e.g. a custom `App\Relations\HasMany` that does not extend
75-
Eloquent's) would be incorrectly classified as a relationship.
47+
**Impact: Low · Effort: Low (fixed)**
7648

77-
The fix would be to resolve the return type to its FQN (using the
78-
class loader or use-map) and verify it lives under
79-
`Illuminate\Database\Eloquent\Relations\` (or extends a class that
80-
does) before classifying. The short-name-only path could remain as a
81-
fast-path fallback when the FQN is already in the
82-
`Illuminate\Database\Eloquent\Relations` namespace.
49+
**Status:** Fixed. `classify_relationship` now checks whether a
50+
namespace-qualified return type lives under
51+
`Illuminate\Database\Eloquent\Relations\` before classifying.
52+
Unqualified short names (the common case for body-inferred types and
53+
use-imported docblock annotations) still match by short name only.
54+
A custom `App\Relations\HasMany` is no longer misclassified.
8355

8456
---
8557

@@ -139,23 +111,26 @@ error message.
139111
---
140112

141113
## 13. Evict transiently-loaded files from ast_map after GTI and Find References
142-
**Impact: Low · Effort: Low**
114+
**Impact: Low · Effort: Low (fixed)**
143115

144-
Go-to-implementation (Phases 3 and 5) and Find References
145-
(`ensure_workspace_indexed`) parse files from disk and cache them in
146-
`ast_map`. Most of these are false positives that passed the cheap
147-
string pre-filter but don't actually contain matching symbols. Even the
148-
true matches are rarely needed afterwards (the user will open the one
149-
they care about through the editor, which triggers a fresh `did_open`).
116+
**Status:** Fixed. `find_implementors` snapshots `ast_map` keys before
117+
scanning and calls `evict_transient_entries` afterwards, removing any
118+
`ast_map`, `symbol_maps`, `use_map`, and `namespace_map` entries that
119+
were added during the scan and are not in `open_files`.
120+
`ensure_workspace_indexed` does the same via
121+
`evict_transient_ast_entries`, which preserves `symbol_maps` (since
122+
those are the purpose of the indexing scan) but evicts the other maps.
150123

151-
Keeping these files in the ast_map wastes memory and pollutes subsequent
152-
Phase 1 scans with classes from files that aren't part of the user's
153-
working set.
124+
---
125+
126+
## 14. Signature help fires on function definition sites
127+
**Impact: Low · Effort: Low**
154128

155-
**Fix:** After `find_implementors` returns, remove any `ast_map` entries
156-
whose URI was not already present before the scan started. Same for
157-
`ensure_workspace_indexed`. Collect the set of existing URIs before the
158-
scan, then evict the difference afterwards. Files that are in
159-
`open_files` must never be evicted.
129+
Signature help triggers when the cursor is inside the parameter list of
130+
a function *definition* (e.g. `function unionDemo(string|int $value, ?User $maybe)`).
131+
Method definitions already suppress this, but standalone function
132+
definitions do not receive the same treatment.
160133

161-
**Discovered via:** fixture conversion (call_expression/static_factory_return_self).
134+
The fix should mirror the method-definition suppression: detect that the
135+
cursor is inside a function declaration's parameter list and return
136+
`None` before attempting signature help resolution.

docs/todo/laravel.md

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -438,23 +438,3 @@ accessors, so in most cases the accessor method itself already produces
438438
the virtual property. Parsing `$appends` would only help when the
439439
accessor is defined in an unloaded parent class.
440440

441-
#### 13. Relationship classification matches short name only
442-
443-
| | |
444-
|---|---|
445-
| **Impact** | ★ — Nearly all codebases use Laravel's built-in relationship classes, so false positives are rare in practice. |
446-
| **Effort** | ★★ — Need to resolve the return type's FQN before matching, which may require a class loader call. |
447-
448-
`classify_relationship` in `virtual_members/laravel.rs` strips the
449-
return type down to its short name (via `short_name`) and matches
450-
against a hardcoded list (`HasMany`, `BelongsTo`, etc.). This means
451-
any class whose short name collides with a Laravel relationship class
452-
(e.g. a custom `App\Relations\HasMany` that does not extend
453-
Eloquent's) would be incorrectly classified as a relationship.
454-
455-
The fix would be to resolve the return type to its FQN (using the
456-
class loader or use-map) and verify it lives under
457-
`Illuminate\Database\Eloquent\Relations\` (or extends a class that
458-
does) before classifying. The short-name-only path could remain as a
459-
fast-path fallback when the FQN is already in the
460-
`Illuminate\Database\Eloquent\Relations` namespace.

src/definition/implementation.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,34 @@ impl Backend {
4747
uri: &str,
4848
content: &str,
4949
position: Position,
50+
) -> Option<Vec<Location>> {
51+
// Snapshot ast_map keys before the scan so we can evict
52+
// transiently-loaded entries afterwards (see §13 in bugs.md).
53+
let pre_scan_uris: HashSet<String> = self
54+
.ast_map
55+
.lock()
56+
.ok()
57+
.map(|m| m.keys().cloned().collect())
58+
.unwrap_or_default();
59+
60+
let result = self.resolve_implementation_inner(uri, content, position);
61+
62+
// Evict ast_map entries that were added during scanning
63+
// (Phases 3 and 5 of find_implementors) but are not open in
64+
// the editor. This must happen after locate_class_declaration
65+
// has read the cached content.
66+
self.evict_transient_entries(&pre_scan_uris);
67+
68+
result
69+
}
70+
71+
/// Inner implementation of [`resolve_implementation`] that performs
72+
/// the actual symbol resolution without eviction concerns.
73+
fn resolve_implementation_inner(
74+
&self,
75+
uri: &str,
76+
content: &str,
77+
position: Position,
5078
) -> Option<Vec<Location>> {
5179
// ── 1. Extract the word under the cursor ────────────────────────
5280
// Primary path: consult the precomputed symbol map.

src/references/mod.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -775,6 +775,15 @@ impl Backend {
775775
/// `composer.json` `config.vendor-dir`, defaulting to `vendor`) is
776776
/// skipped during the filesystem walk.
777777
fn ensure_workspace_indexed(&self) {
778+
// Snapshot ast_map keys before scanning so we can evict
779+
// transiently-loaded entries afterwards (see §13 in bugs.md).
780+
let pre_scan_uris: HashSet<String> = self
781+
.ast_map
782+
.lock()
783+
.ok()
784+
.map(|m| m.keys().cloned().collect())
785+
.unwrap_or_default();
786+
778787
// Collect URIs that already have symbol maps.
779788
let existing_uris: HashSet<String> = self
780789
.symbol_maps
@@ -860,6 +869,13 @@ impl Backend {
860869
}
861870
}
862871
}
872+
873+
// ── Evict transient ast_map entries ─────────────────────────────
874+
// The scan above may have added files to ast_map, use_map, and
875+
// namespace_map that are not open in the editor. Symbol maps are
876+
// preserved (they are the whole point of this scan), but the other
877+
// maps can be evicted to save memory.
878+
self.evict_transient_ast_entries(&pre_scan_uris);
863879
}
864880
}
865881

src/util.rs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,94 @@ impl Backend {
761761
}
762762
}
763763

764+
/// Evict `ast_map` (and associated map) entries that were added
765+
/// during a transient scan (go-to-implementation, find references).
766+
///
767+
/// `pre_scan_uris` is the set of URIs that were already in `ast_map`
768+
/// before the scan started. Any URI that is now in `ast_map` but was
769+
/// not in `pre_scan_uris` — and is not currently open in the editor —
770+
/// is removed from `ast_map`, `symbol_maps`, `use_map`, and
771+
/// `namespace_map`. This prevents memory bloat from files that were
772+
/// parsed only to check whether they contain a matching symbol.
773+
///
774+
/// Use [`evict_transient_ast_entries`](Self::evict_transient_ast_entries)
775+
/// instead when `symbol_maps` should be preserved (e.g. after
776+
/// `ensure_workspace_indexed`, where the symbol maps are the whole
777+
/// point of the scan).
778+
pub(crate) fn evict_transient_entries(
779+
&self,
780+
pre_scan_uris: &std::collections::HashSet<String>,
781+
) {
782+
self.evict_transient_inner(pre_scan_uris, true);
783+
}
784+
785+
/// Like [`evict_transient_entries`](Self::evict_transient_entries)
786+
/// but preserves `symbol_maps`.
787+
///
788+
/// `ensure_workspace_indexed` builds symbol maps so that find
789+
/// references can scan them. The `ast_map`, `use_map`, and
790+
/// `namespace_map` entries for those files are no longer needed
791+
/// after indexing and can be evicted to save memory.
792+
pub(crate) fn evict_transient_ast_entries(
793+
&self,
794+
pre_scan_uris: &std::collections::HashSet<String>,
795+
) {
796+
self.evict_transient_inner(pre_scan_uris, false);
797+
}
798+
799+
/// Shared implementation for transient entry eviction.
800+
///
801+
/// When `evict_symbol_maps` is true, `symbol_maps` entries are
802+
/// removed alongside `ast_map`, `use_map`, and `namespace_map`.
803+
fn evict_transient_inner(
804+
&self,
805+
pre_scan_uris: &std::collections::HashSet<String>,
806+
evict_symbol_maps: bool,
807+
) {
808+
// Collect URIs that were added during the scan.
809+
let new_uris: Vec<String> = self
810+
.ast_map
811+
.lock()
812+
.ok()
813+
.map(|m| {
814+
m.keys()
815+
.filter(|uri| !pre_scan_uris.contains(*uri))
816+
.cloned()
817+
.collect()
818+
})
819+
.unwrap_or_default();
820+
821+
if new_uris.is_empty() {
822+
return;
823+
}
824+
825+
// Never evict files that are currently open in the editor.
826+
let open: std::collections::HashSet<String> = self
827+
.open_files
828+
.lock()
829+
.ok()
830+
.map(|f| f.keys().cloned().collect())
831+
.unwrap_or_default();
832+
833+
for uri in &new_uris {
834+
if open.contains(uri) {
835+
continue;
836+
}
837+
if let Ok(mut map) = self.ast_map.lock() {
838+
map.remove(uri);
839+
}
840+
if evict_symbol_maps && let Ok(mut map) = self.symbol_maps.lock() {
841+
map.remove(uri);
842+
}
843+
if let Ok(mut map) = self.use_map.lock() {
844+
map.remove(uri);
845+
}
846+
if let Ok(mut map) = self.namespace_map.lock() {
847+
map.remove(uri);
848+
}
849+
}
850+
}
851+
764852
pub(crate) async fn log(&self, typ: MessageType, message: String) {
765853
if let Some(client) = &self.client {
766854
client.log_message(typ, message).await;

0 commit comments

Comments
 (0)