Skip to content

Commit ded4f96

Browse files
committed
Lower memory use for member access spans
1 parent 3195f1e commit ded4f96

27 files changed

Lines changed: 247 additions & 127 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4444
- **Updated the bundled mago toolchain to 1.43.0.** The parser, docblock parser, formatter, and supporting crates are refreshed to the latest upstream release. Contributed by @enwi in https://github.com/PHPantom-dev/phpantom_lsp/pull/234.
4545
- **Lower memory use in the cross-file reference index.** The index backing Find References and the reference-count inlay hints now keeps only the distinct files and counts each symbol actually needs, instead of one entry per matching location plus never-read position data. On large projects this removes millions of short-lived allocations and shrinks the index to a fraction of its previous size, with no change to Find References or inlay hint results.
4646
- **Lower memory use for method lookups.** Each resolved class's method name index is now a sorted list searched with binary search instead of a hash map, using about a third of the memory for the same lookup speed. On large Laravel projects, where the resolved-class cache holds thousands of these indexes, this measurably shrinks total memory use.
47+
- **Lower memory use for member access spans.** The subject text recorded for every `->`/`::` access (e.g. `$this` in `$this->save()`) no longer allocates a string when it is a plain slice of the source, which covers the vast majority of accesses in typical PHP code. It now reuses the file's own bytes instead, with an allocation only for the rarer cases (chained calls, `new` expressions) where the recorded text differs from the source.
4748

4849
### Removed
4950

docs/todo.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ within the same impact tier.
2828
| P36 | [Diagnostic-path call sites re-merge inheritance per call](todo/performance.md#p36-diagnostic-path-call-sites-re-merge-inheritance-per-call-instead-of-reading-the-resolved-class-cache) | Medium | Low |
2929
| P38 | [Resolved type values are duplicated ~34x](todo/performance.md#p38-resolved-type-values-are-duplicated-34x) | High | High |
3030
| P41 | [3.8 M live allocations cost ~170 MB in allocator overhead](todo/performance.md#p41-38-m-live-allocations-cost-170-mb-in-allocator-overhead) | Medium | Medium |
31-
| P43 | [`MemberAccess.subject_text` still allocates a `String` per span](todo/performance.md#p43-memberaccesssubject_text-still-allocates-a-string-per-span) | Low-Medium | Medium |
3231
| P33 | [Workspace diagnostics leaves the whole project fully resolved in memory](todo/performance.md#p33-workspace-diagnostics-leaves-the-whole-project-fully-resolved-in-memory) | High | Medium-High |
3332
| X10 | [Interactive requests block on the workspace index lock during initial indexing](todo/indexing.md#x10-interactive-requests-block-on-the-workspace-index-lock-during-initial-indexing) | Medium | Medium |
3433
| L21 | [Tighten the supertype-where-subtype comparison escape hatch (blocked on resolver precision)](todo/laravel.md#l21-tighten-the-supertype-where-subtype-comparison-escape-hatch-blocked-on-resolver-precision) | Medium | High |
@@ -115,6 +114,7 @@ unlikely to move the needle for most users.
115114
| D15 | [Unused parameter diagnostic](todo/diagnostics.md#d15-unused-parameter-diagnostic) | Low | Low |
116115
| | **[Bug Fixes](todo/bugs.md)** | | |
117116
| B1 | [Rename can build edits from a symbol map that predates the buffer](todo/bugs.md#b1-rename-can-build-edits-from-a-symbol-map-that-predates-the-buffer) | Medium-High | Low-Medium |
117+
| B2 | [`mem-audit` feature build fails: `Ustr::capacity()` does not exist](todo/bugs.md#b2-mem-audit-feature-build-fails-ustrcapacity-does-not-exist) | Low | Low |
118118
| | **[Code Actions](todo/actions.md)** | | |
119119
| A40 | [Generate method from call](todo/actions.md#a40-generate-method-from-call) | Medium-High | Medium |
120120
| A41 | [Create class from non-existing name](todo/actions.md#a41-create-class-from-non-existing-name) | Medium | Medium |

docs/todo/bugs.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,39 @@ Note that the cross-file paths need slightly more than linked editing
4141
did: they read maps for files other than the request URI, so each one
4242
must be checked against *that* file's content, not the buffer the
4343
request arrived on.
44+
45+
## B2 `mem-audit` feature build fails: `Ustr::capacity()` does not exist
46+
47+
`cargo build --features mem-audit` fails on `main` (reproduced before
48+
any local changes, via `git stash`):
49+
50+
```
51+
error[E0599]: no method named `capacity` found for reference `&Ustr` in the current scope
52+
--> src/mem_audit.rs:1269:27
53+
|
54+
1269 | sym.add(k.capacity());
55+
| ^^^^^^^^ method not found in `&Ustr`
56+
57+
error[E0599]: no method named `capacity` found for reference `&Ustr` in the current scope
58+
--> src/mem_audit.rs:1271:34
59+
|
60+
1271 | member_idx.add(k.capacity());
61+
| ^^^^^^^^ method not found in `&Ustr`
62+
```
63+
64+
`k` in both call sites is a `Ustr` (interned string handle from the
65+
`ustr` crate) used as a hash map key, and the audit code is calling
66+
`.capacity()` on it to account for heap bytes the way it does for
67+
`String`/`Box<str>` keys elsewhere in the same file. `Ustr` has no
68+
`capacity()` method — it is a pointer into a global intern table, not
69+
an owned allocation, so the right accounting is either to treat it as
70+
zero marginal heap cost (the string data is shared and already counted
71+
once wherever it's interned) or to look up whatever `ustr` exposes for
72+
the underlying byte length, not capacity.
73+
74+
The current profile (dev, `cargo build --lib`) never builds this
75+
feature, so normal development and `cargo test` are unaffected. It only
76+
surfaces when actually running the memory-audit binary, which is
77+
presumably why it went unnoticed. Fix by correcting the two call sites
78+
in `src/mem_audit.rs` to match whatever the installed `ustr` version's
79+
API actually provides.

docs/todo/performance.md

Lines changed: 0 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1215,62 +1215,6 @@ headless mode rather than special-casing the `analyze` call site.
12151215

12161216
---
12171217

1218-
## P43. `MemberAccess.subject_text` still allocates a `String` per span
1219-
1220-
**Impact: Low-Medium · Effort: Medium**
1221-
1222-
Every other name field on `SymbolKind` (`member_name`, class names,
1223-
variable names, function names, constant names) is now `Atom`,
1224-
eliminating their per-span allocation. `MemberAccess::subject_text` is
1225-
the one field left as an owned `String`, and it is not a simple
1226-
byte-range problem the way it first looked.
1227-
1228-
`subject_text` is not a slice of the file's raw source — it is the
1229-
*synthesized* canonical text `expr_to_subject_text` builds from the AST
1230-
(`symbol_map/extraction/subject_text.rs`, `type_engine/subject_expr.rs`).
1231-
It diverges from the raw bytes in ways consumers actually depend on:
1232-
1233-
- `(new Foo($x))->bar()` lowers straight to `"Foo"`, dropping `new` and
1234-
the constructor arguments entirely — documented as intentional,
1235-
matching historical behaviour ("the constructed instance resolves
1236-
through its class").
1237-
- Whitespace around `->`/`::` is normalised away (`$this -> foo` still
1238-
serialises to `"$this->foo"`); a raw source slice would keep the
1239-
spaces and break the byte-exact splitting `SubjectExpr::parse` does
1240-
(`split_last_arrow_raw` and friends have no whitespace tolerance).
1241-
- Expressions the lowering can't represent collapse to `""`, and
1242-
several consumers key off `.is_empty()` as an explicit
1243-
"unresolvable" sentinel (e.g. `resolve_target_classes` callers). A
1244-
raw slice would hand them real, non-empty text instead and defeat
1245-
that check.
1246-
1247-
Storing `(start, end)` into the file content and slicing at request
1248-
time would reproduce none of this and would silently change subject
1249-
resolution for the shapes above — exactly the class of silently-wrong
1250-
result the project's no-diagnostic-suppression rule exists to prevent
1251-
for diagnostics, and the same logic applies to any other resolution
1252-
path.
1253-
1254-
A safe version of the same idea is still possible, just bigger than a
1255-
plain byte range:
1256-
1257-
1. Add an enum (`SubjectText::Range { start, end } |
1258-
SubjectText::Owned(Box<str>)`), chosen at extraction time by
1259-
comparing the synthesized text against the raw byte range of the
1260-
object expression and only paying for `Owned` when they differ
1261-
(nested calls, `new`-as-subject, unsupported forms).
1262-
2. Thread the file content through the ~15 call sites across `hover`,
1263-
`definition`, `references`, `diagnostics`, `code_actions`, and the
1264-
`symbol_map` test helpers that currently read `subject_text` as a
1265-
bare `&str`, since `SubjectText::as_str` needs it to resolve the
1266-
`Range` case.
1267-
1268-
Worth re-measuring after: most `MemberAccess` subjects in typical PHP
1269-
code are plain variables/`$this`/class names, which take the free
1270-
`Range` path, so the `Owned` fallback should be rare in practice.
1271-
1272-
---
1273-
12741218
# Remaining anti-pattern fixes
12751219

12761220
Most remaining depth-cap issues were addressed by eager class

src/code_actions/import_class.rs

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ impl Backend {
239239
subject_text,
240240
is_static: true,
241241
..
242-
} => subject_text.as_str(),
242+
} => subject_text.as_str(content),
243243
_ => continue,
244244
};
245245

@@ -585,11 +585,12 @@ impl Backend {
585585
subject_text,
586586
is_static: true,
587587
..
588-
} if !subject_text.starts_with('$')
589-
&& !subject_text.contains('\\')
590-
&& !is_class_keyword(subject_text) =>
588+
} if {
589+
let s = subject_text.as_str(content);
590+
!s.starts_with('$') && !s.contains('\\') && !is_class_keyword(s)
591+
} =>
591592
{
592-
subject_text.as_str()
593+
subject_text.as_str(content)
593594
}
594595
_ => continue,
595596
};
@@ -794,11 +795,12 @@ impl Backend {
794795
subject_text,
795796
is_static: true,
796797
..
797-
} if !subject_text.starts_with('$')
798-
&& !subject_text.contains('\\')
799-
&& !is_class_keyword(subject_text) =>
798+
} if {
799+
let s = subject_text.as_str(content);
800+
!s.starts_with('$') && !s.contains('\\') && !is_class_keyword(s)
801+
} =>
800802
{
801-
(subject_text.as_str(), ClassRefContext::Other)
803+
(subject_text.as_str(content), ClassRefContext::Other)
802804
}
803805
_ => continue,
804806
};

src/code_actions/replace_deprecated.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ impl Backend {
143143

144144
// Resolve the subject to find the method's replacement template.
145145
let base_class = resolve_subject_to_class(
146-
subject_text,
146+
subject_text.as_str(content),
147147
*is_static,
148148
&file_ctx,
149149
span.start,
@@ -203,7 +203,7 @@ impl Backend {
203203
continue;
204204
};
205205

206-
let subject = Some(subject_text.trim().to_string());
206+
let subject = Some(subject_text.as_str(content).trim().to_string());
207207
let replacement =
208208
expand_template(&replacement_template, &args_text, subject.as_deref());
209209

src/completion/handler/member_access.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,7 @@ impl Backend {
465465
};
466466
return Some(CompletionTarget {
467467
access_kind,
468-
subject: subject_text.clone(),
468+
subject: subject_text.as_str(content).to_string(),
469469
});
470470
}
471471

src/definition/member/mod.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -699,13 +699,13 @@ impl Backend {
699699
let offset = position_to_offset(content, position);
700700

701701
// Try the symbol map (primary path).
702-
if let Some(result) = self.member_access_from_symbol_map(uri, offset) {
702+
if let Some(result) = self.member_access_from_symbol_map(uri, content, offset) {
703703
return Some(result);
704704
}
705705
// Retry with offset − 1 for the end-of-token edge case (cursor
706706
// right after the last character of the member name).
707707
if offset > 0
708-
&& let Some(result) = self.member_access_from_symbol_map(uri, offset - 1)
708+
&& let Some(result) = self.member_access_from_symbol_map(uri, content, offset - 1)
709709
{
710710
return Some(result);
711711
}
@@ -718,6 +718,7 @@ impl Backend {
718718
fn member_access_from_symbol_map(
719719
&self,
720720
uri: &str,
721+
content: &str,
721722
offset: u32,
722723
) -> Option<(String, AccessKind)> {
723724
let maps = self.symbol_maps.read();
@@ -734,7 +735,7 @@ impl Backend {
734735
} else {
735736
AccessKind::Arrow
736737
};
737-
Some((subject_text.clone(), access_kind))
738+
Some((subject_text.as_str(content).to_string(), access_kind))
738739
}
739740
_ => None,
740741
}

src/definition/resolve.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ impl Backend {
276276
};
277277
let mctx = MemberDefinitionCtx {
278278
member_name,
279-
subject: subject_text,
279+
subject: subject_text.as_str(content),
280280
access_kind,
281281
access_hint,
282282
};

src/definition/type_definition.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ impl Backend {
103103

104104
let candidates = ResolvedType::into_arced_classes(
105105
crate::type_engine::resolver::resolve_target_classes(
106-
subject_text,
106+
subject_text.as_str(content),
107107
access_kind,
108108
&rctx,
109109
),

0 commit comments

Comments
 (0)