Skip to content

Commit e91c13a

Browse files
committed
SymbolKind stores owned strings per span
1 parent b89d74d commit e91c13a

21 files changed

Lines changed: 193 additions & 147 deletions

File tree

docs/todo.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ within the same impact tier.
2727
| --- | --------------------------------------------------------------------------------------------------------------------- | ---------- | ------ |
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 |
30-
| P39 | [`SymbolKind` stores owned strings per span](todo/performance.md#p39-symbolkind-stores-owned-strings-per-span) | Medium | Low-Medium |
3130
| P40 | [`method_index` is a per-class `HashMap` even when the member vec is shared](todo/performance.md#p40-method_index-is-a-per-class-hashmap-even-when-the-member-vec-is-shared) | Low-Medium | Low |
3231
| 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 |
32+
| 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 |
3333
| 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 |
3434
| 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 |
3535
| 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 |

docs/todo/performance.md

Lines changed: 56 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1156,38 +1156,6 @@ should keep owned payloads or use a separate bounded cache.
11561156

11571157
---
11581158

1159-
## P39. `SymbolKind` stores owned strings per span
1160-
1161-
**Impact: Medium · Effort: Low-Medium**
1162-
1163-
`symbol_maps` is the second-largest store: 40 MB / 367 K allocations on
1164-
the mid-size app, 64 MB / 602 K on the large one. The span vectors
1165-
dominate it — 353 K spans costing 31 MB of struct bytes (88 B per
1166-
`SymbolSpan`) plus 4.5 MB of string payloads across 254 K separate
1167-
allocations.
1168-
1169-
`SymbolSpan` is `{ start: u32, end: u32, kind: SymbolKind }`, and
1170-
`SymbolKind` is fat because nearly every variant owns a `String`.
1171-
`MemberAccess` owns two (`subject_text` and `member_name`).
1172-
1173-
Two independent changes:
1174-
1175-
1. **Interned names.** `member_name`, class names, variable names,
1176-
function names, and constant names are all drawn from bounded sets
1177-
already interned elsewhere. Making them `Atom` drops 16 B per field
1178-
and removes the per-span allocation entirely.
1179-
2. **`subject_text` as a byte range.** It is the source text of the LHS
1180-
expression, which is unbounded free text and must not be interned.
1181-
Every consumer has the file content available, so storing
1182-
`(start, end)` offsets and slicing on demand removes the string
1183-
without losing anything.
1184-
1185-
Together these should take `SymbolKind` from ~80 B to ~24-32 B,
1186-
reclaiming roughly 17 MB on the large app and eliminating ~250 K
1187-
allocations.
1188-
1189-
---
1190-
11911159
## P40. `method_index` is a per-class `HashMap` even when the member vec is shared
11921160

11931161
**Impact: Low-Medium · Effort: Low**
@@ -1275,6 +1243,62 @@ headless mode rather than special-casing the `analyze` call site.
12751243

12761244
---
12771245

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

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

src/definition/resolve.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -357,11 +357,11 @@ impl Backend {
357357
let ctx = self.file_context(uri);
358358
let fqn = ctx.resolve_name_at(name, cursor_offset);
359359
let mut candidates = vec![fqn];
360-
if name.contains('\\') && !candidates.contains(name) {
361-
candidates.push(name.clone());
360+
if name.contains('\\') && !candidates.iter().any(|c| c == name) {
361+
candidates.push(name.to_string());
362362
}
363-
if !candidates.contains(name) {
364-
candidates.push(name.clone());
363+
if !candidates.iter().any(|c| c == name) {
364+
candidates.push(name.to_string());
365365
}
366366
self.resolve_function_definition(&candidates)
367367
.map(|loc| vec![loc])
@@ -371,8 +371,8 @@ impl Backend {
371371
let ctx = self.file_context(uri);
372372
let fqn = ctx.resolve_name_at(name, cursor_offset);
373373
let mut candidates = vec![fqn];
374-
if !candidates.contains(name) {
375-
candidates.push(name.clone());
374+
if !candidates.iter().any(|c| c == name) {
375+
candidates.push(name.to_string());
376376
}
377377
// Try class constant (Name::CONST) first — but the symbol
378378
// map records class constants as MemberAccess, so this path

src/diagnostics/unknown_functions.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ impl Backend {
107107
name,
108108
is_definition: true,
109109
} => {
110-
let mut names = vec![name.clone()];
110+
let mut names = vec![name.to_string()];
111111
if let Some(ns) = file_namespace {
112112
names.push(format!("{}\\{}", ns, name));
113113
}

src/highlight/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ impl Backend {
6161
SymbolKind::ClassReference { name, is_fqn, .. } => {
6262
let ctx = self.file_context(uri);
6363
let fqn = if *is_fqn {
64-
name.clone()
64+
name.to_string()
6565
} else {
6666
ctx.resolve_name_at(name, span.start)
6767
};
@@ -224,7 +224,7 @@ impl Backend {
224224
let fqn = match &span.kind {
225225
SymbolKind::ClassReference { name, is_fqn, .. } => {
226226
if *is_fqn {
227-
name.clone()
227+
name.to_string()
228228
} else {
229229
Self::resolve_to_fqn(name, use_map, namespace)
230230
}

src/reference_index.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -652,7 +652,7 @@ mod tests {
652652
start: 0,
653653
end: name.len() as u32,
654654
kind: SymbolKind::ClassDeclaration {
655-
name: name.to_string(),
655+
name: crate::atom::atom(name),
656656
},
657657
}],
658658
..SymbolMap::default()

src/references/classes.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ impl Backend {
9494
let matched = match &span.kind {
9595
SymbolKind::ClassReference { name, is_fqn, .. } => {
9696
let resolved = if *is_fqn {
97-
name.clone()
97+
name.to_string()
9898
} else if let Some(fqn) =
9999
resolved_names.as_ref().and_then(|rn| rn.get(span.start))
100100
{

src/references/dispatch.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ impl Backend {
180180
SymbolKind::ClassReference { name, is_fqn, .. } => {
181181
let ctx = self.file_context(uri);
182182
let fqn = if *is_fqn {
183-
name.clone()
183+
name.to_string()
184184
} else {
185185
ctx.resolve_name_at(name, span_start)
186186
};

src/rename/class.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ impl Backend {
3232
SymbolKind::ClassReference { name, is_fqn, .. } => {
3333
let ctx = self.file_context(uri);
3434
let fqn = if *is_fqn {
35-
name.clone()
35+
name.to_string()
3636
} else {
3737
ctx.resolve_name_at(name, offset)
3838
};

src/rename/prepare.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -226,14 +226,14 @@ impl Backend {
226226
// Include the `$` prefix in the range — the span already does.
227227
Some((format!("${}", name), range))
228228
}
229-
SymbolKind::CompactVariable { name } => Some((name.clone(), range)),
230-
SymbolKind::ClassReference { name, .. } => Some((name.clone(), range)),
231-
SymbolKind::ClassDeclaration { name } => Some((name.clone(), range)),
232-
SymbolKind::MemberAccess { member_name, .. } => Some((member_name.clone(), range)),
233-
SymbolKind::MemberDeclaration { name, .. } => Some((name.clone(), range)),
234-
SymbolKind::FunctionCall { name, .. } => Some((name.clone(), range)),
235-
SymbolKind::ConstantReference { name } => Some((name.clone(), range)),
236-
SymbolKind::NamespaceDeclaration { name } => Some((name.clone(), range)),
229+
SymbolKind::CompactVariable { name } => Some((name.to_string(), range)),
230+
SymbolKind::ClassReference { name, .. } => Some((name.to_string(), range)),
231+
SymbolKind::ClassDeclaration { name } => Some((name.to_string(), range)),
232+
SymbolKind::MemberAccess { member_name, .. } => Some((member_name.to_string(), range)),
233+
SymbolKind::MemberDeclaration { name, .. } => Some((name.to_string(), range)),
234+
SymbolKind::FunctionCall { name, .. } => Some((name.to_string(), range)),
235+
SymbolKind::ConstantReference { name } => Some((name.to_string(), range)),
236+
SymbolKind::NamespaceDeclaration { name } => Some((name.to_string(), range)),
237237
SymbolKind::LaravelMacroString { name } => Some((name.clone(), range)),
238238
SymbolKind::SelfStaticParent { .. } => None,
239239
SymbolKind::LaravelStringKey { .. }

0 commit comments

Comments
 (0)