Skip to content

Commit d94d989

Browse files
committed
Deduplictae methods and properties
1 parent 468a0e1 commit d94d989

44 files changed

Lines changed: 960 additions & 275 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3535

3636
### Changed
3737

38-
- **Lower memory use when flattening generic class hierarchies.** Resolving a subclass of a generic base class no longer copies every inherited method and property. Members that do not mention a generic type parameter are now shared with the base class instead of being cloned and rewritten. On large Laravel projects, where nearly every Eloquent model resolves through a generic base, this substantially shrinks the resolved-class cache during workspace analysis and speeds up the merge itself.
38+
- **Lower memory use when resolving class hierarchies.** Resolving a class no longer copies every inherited or synthesized member into it. Members that a merge does not actually rewrite are shared with their source across the whole workspace, and the copies a merge does produce (template substitution, trait and interface merging, Eloquent Builder and scope forwarding) are deduplicated so that value-identical results share a single allocation instead of one per class. Method parameter lists are shared the same way. On large Laravel projects, where nearly every Eloquent model resolves through a generic base and forwards the same query-builder methods, this roughly halves the memory held by the resolved-class cache during whole-project analysis and speeds up the merge itself.
3939
- **Classes are pre-resolved for the whole workspace after startup.** Once the background index completes, every known class is resolved in dependency order even when workspace diagnostics are disabled, so the first completion, hover, or go-to-definition against any class reads a warm cache instead of resolving on demand. Edits still re-resolve only the affected classes.
4040
- **Continuous progress reporting.** The indexing progress bar now advances file by file with live counts (e.g. "Scanning vendor packages (3201/8544 files)") instead of jumping between a few fixed milestones. This covers single-project, monorepo, and non-Composer workspaces. Go to Implementation, Find References, and Type Hierarchy show the same live progress while they scan, including when one of them triggers the first full workspace index.
4141
- **Property hover now shows effective types as a `var` detail line.** Property hovers now mirror method hovers by displaying the resolved/effective property type above the PHP snippet as `**var**`, while the snippet itself shows only the native PHP property declaration. This keeps docblock-inferred, virtual, and schema-derived property types out of the generated signature block. Contributed by @calebdw.

src/code_actions/extract_interface.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -511,7 +511,7 @@ mod tests {
511511
Arc::new(MethodInfo {
512512
name: Atom::from(name),
513513
name_offset: 0,
514-
parameters: vec![],
514+
parameters: vec![].into(),
515515
return_type: None,
516516
native_return_type: None,
517517
description: None,

src/code_actions/implement_methods.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -709,7 +709,8 @@ mod tests {
709709
is_reference: false,
710710
closure_this_type: None,
711711
},
712-
],
712+
]
713+
.into(),
713714
..MethodInfo::virtual_method("test", None)
714715
};
715716

@@ -731,7 +732,8 @@ mod tests {
731732
is_variadic: false,
732733
is_reference: false,
733734
closure_this_type: None,
734-
}],
735+
}]
736+
.into(),
735737
..MethodInfo::virtual_method("getAttribute", None)
736738
};
737739
let result = format_params(&method, &HashMap::new(), &None);
@@ -765,7 +767,8 @@ mod tests {
765767
is_reference: true,
766768
closure_this_type: None,
767769
},
768-
],
770+
]
771+
.into(),
769772
..MethodInfo::virtual_method("test", None)
770773
};
771774

@@ -1188,7 +1191,8 @@ mod tests {
11881191
is_reference: false,
11891192
closure_this_type: None,
11901193
},
1191-
],
1194+
]
1195+
.into(),
11921196
native_return_type: Some(PhpType::parse("void")),
11931197
visibility: Visibility::Public,
11941198
..MethodInfo::virtual_method("process", Some("void"))

src/completion/context/class_completion/ctor.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use tower_lsp::lsp_types::InsertTextFormat;
33

44
use crate::Backend;
55
use crate::completion::builder::build_callable_snippet;
6-
use crate::types::ParameterInfo;
6+
use crate::types::{ParameterInfo, SharedVec};
77

88
impl Backend {
99
/// Build the insert text (and optional format) for a `new` context
@@ -30,7 +30,7 @@ impl Backend {
3030
/// Tries `load_stub_class` (which checks `uri_classes_index` first, then
3131
/// in-memory stubs) to avoid disk I/O. Returns `None` when the
3232
/// class cannot be found or has no constructor.
33-
pub(super) fn ctor_params_for(&self, class_name: &str) -> Option<Vec<ParameterInfo>> {
33+
pub(super) fn ctor_params_for(&self, class_name: &str) -> Option<SharedVec<ParameterInfo>> {
3434
let cls = self.load_stub_class(class_name)?;
3535
let ctor = cls.get_method_ci("__construct")?;
3636
Some(ctor.parameters.clone())

src/completion/handler/named_args.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ impl Backend {
159159
content: &str,
160160
position: Position,
161161
file_ctx: &FileContext,
162-
) -> Vec<crate::types::ParameterInfo> {
162+
) -> crate::types::SharedVec<crate::types::ParameterInfo> {
163163
self.resolve_callable_target(&ctx.call_expression, content, position, file_ctx)
164164
.map(|r| r.parameters)
165165
.unwrap_or_default()

src/completion/source/throws_analysis_tests.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -796,7 +796,7 @@ fn make_class_with_throws(name: &str, methods: Vec<(&str, Vec<&str>)>) -> Arc<Cl
796796
.map(|(method_name, throws)| MethodInfo {
797797
name: crate::atom::atom(method_name),
798798
name_offset: 0,
799-
parameters: Vec::new(),
799+
parameters: Vec::new().into(),
800800
return_type: None,
801801
native_return_type: None,
802802
description: None,
@@ -1143,7 +1143,7 @@ fn test_find_cross_file_propagated_throws_function_call() {
11431143
let func_info = FunctionInfo {
11441144
name: crate::atom::atom("riskyFunction"),
11451145
name_offset: 0,
1146-
parameters: Vec::new(),
1146+
parameters: Vec::new().into(),
11471147
return_type: None,
11481148
native_return_type: None,
11491149
description: None,
@@ -1277,7 +1277,7 @@ fn test_find_cross_file_propagated_throws_mixed_patterns() {
12771277
let func_info = FunctionInfo {
12781278
name: crate::atom::atom("helperFunction"),
12791279
name_offset: 0,
1280-
parameters: Vec::new(),
1280+
parameters: Vec::new().into(),
12811281
return_type: None,
12821282
native_return_type: None,
12831283
description: None,

src/docblock/virtual_members.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ pub fn extract_method_tags(docblock: &str) -> Vec<MethodInfo> {
241241
results.push(MethodInfo {
242242
name: method_atom,
243243
name_offset: 0,
244-
parameters,
244+
parameters: parameters.into(),
245245
return_type,
246246
native_return_type: None,
247247
description: None,

src/document_symbols.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -567,7 +567,7 @@ mod tests {
567567
let method = MethodInfo {
568568
name: crate::atom::atom("foo"),
569569
name_offset: 0,
570-
parameters: vec![],
570+
parameters: vec![].into(),
571571
return_type: Some(PhpType::parse("void")),
572572
native_return_type: None,
573573
description: None,
@@ -623,7 +623,8 @@ mod tests {
623623
is_reference: false,
624624
closure_this_type: None,
625625
},
626-
],
626+
]
627+
.into(),
627628
return_type: Some(PhpType::parse("int")),
628629
native_return_type: None,
629630
description: None,
@@ -753,7 +754,7 @@ mod tests {
753754
let func = FunctionInfo {
754755
name: crate::atom::atom("noop"),
755756
name_offset: 0,
756-
parameters: vec![],
757+
parameters: vec![].into(),
757758
return_type: None,
758759
native_return_type: None,
759760
description: None,

src/inheritance/enrichment.rs

Lines changed: 122 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
//! parameter types, descriptions), this module propagates richer type information
55
//! from ancestors through inheritance chains.
66
7+
use std::sync::Arc;
8+
79
use crate::php_type::PhpType;
810
use crate::types::{MethodInfo, ParameterInfo, PropertyInfo};
911

@@ -45,6 +47,116 @@ fn ancestor_has_richer_type(effective: &Option<PhpType>, native: &Option<PhpType
4547
}
4648
}
4749

50+
/// Copy-on-write wrapper around [`enrich_method_from_ancestor`].
51+
///
52+
/// Most class/ancestor method overlaps have nothing to propagate (the
53+
/// child already carries its own docblock types, or the ancestor has
54+
/// nothing richer to offer). Checking first means the shared
55+
/// `Arc<MethodInfo>` is only deep-cloned when the enrichment actually
56+
/// changes a field, instead of once per overlap per class.
57+
pub(crate) fn enrich_method_arc_from_ancestor(
58+
existing: &mut Arc<MethodInfo>,
59+
ancestor: &MethodInfo,
60+
) {
61+
if method_enrichment_would_change(existing, ancestor) {
62+
enrich_method_from_ancestor(Arc::make_mut(existing), ancestor);
63+
}
64+
}
65+
66+
/// Whether [`enrich_method_from_ancestor`] would change any field of
67+
/// `existing`.
68+
///
69+
/// Mirrors the apply function's conditions exactly (keep the two in
70+
/// sync), with one refinement: a copy that would overwrite a field with
71+
/// an equal value counts as "no change". A `false` here guarantees the
72+
/// enrichment is semantically a no-op, so the caller can skip the
73+
/// copy-on-write clone.
74+
fn method_enrichment_would_change(existing: &MethodInfo, ancestor: &MethodInfo) -> bool {
75+
// Return type.
76+
if (existing.return_type.is_none() && ancestor.return_type.is_some()
77+
|| lacks_docblock_override(&existing.return_type, &existing.native_return_type)
78+
&& ancestor_has_richer_type(&ancestor.return_type, &ancestor.native_return_type))
79+
&& existing.return_type != ancestor.return_type
80+
{
81+
return true;
82+
}
83+
84+
// Template parameters (copies bounds and bindings alongside).
85+
if existing.template_params.is_empty() && !ancestor.template_params.is_empty() {
86+
return true;
87+
}
88+
89+
// Conditional return type.
90+
if existing.conditional_return.is_none() && ancestor.conditional_return.is_some() {
91+
return true;
92+
}
93+
94+
// Type assertions.
95+
if existing.type_assertions.is_empty() && !ancestor.type_assertions.is_empty() {
96+
return true;
97+
}
98+
99+
// Parameters (constructors match by name, everything else by position).
100+
let params_would_change = if existing.name == "__construct" {
101+
constructor_parameters_would_change(&existing.parameters, &ancestor.parameters)
102+
} else {
103+
positional_parameters_would_change(&existing.parameters, &ancestor.parameters)
104+
};
105+
if params_would_change {
106+
return true;
107+
}
108+
109+
// Descriptions.
110+
(existing.description.is_none() && ancestor.description.is_some())
111+
|| (existing.return_description.is_none() && ancestor.return_description.is_some())
112+
}
113+
114+
/// Whether [`enrich_constructor_parameters_by_name`] would change any
115+
/// parameter.
116+
fn constructor_parameters_would_change(
117+
existing_params: &[ParameterInfo],
118+
ancestor_params: &[ParameterInfo],
119+
) -> bool {
120+
existing_params.iter().any(|ep| {
121+
ancestor_params
122+
.iter()
123+
.find(|ap| ap.name == ep.name)
124+
.is_some_and(|ap| parameter_enrichment_would_change(ep, ap))
125+
})
126+
}
127+
128+
/// Whether [`enrich_parameters_from_ancestor`] would change any
129+
/// parameter.
130+
fn positional_parameters_would_change(
131+
existing_params: &[ParameterInfo],
132+
ancestor_params: &[ParameterInfo],
133+
) -> bool {
134+
existing_params
135+
.iter()
136+
.zip(ancestor_params)
137+
.any(|(ep, ap)| parameter_enrichment_would_change(ep, ap))
138+
}
139+
140+
/// Whether [`enrich_single_parameter`] would change `existing_param`.
141+
///
142+
/// Mirrors the apply function's conditions (keep the two in sync).
143+
fn parameter_enrichment_would_change(
144+
existing_param: &ParameterInfo,
145+
ancestor_param: &ParameterInfo,
146+
) -> bool {
147+
let child_has_specific_native = existing_param.native_type_hint.as_ref().is_some_and(|nt| {
148+
!nt.is_object() && !nt.is_mixed() && !nt.is_array_like() && !nt.is_iterable()
149+
});
150+
if !child_has_specific_native
151+
&& lacks_docblock_override(&existing_param.type_hint, &existing_param.native_type_hint)
152+
&& ancestor_has_richer_type(&ancestor_param.type_hint, &ancestor_param.native_type_hint)
153+
&& existing_param.type_hint != ancestor_param.type_hint
154+
{
155+
return true;
156+
}
157+
existing_param.description.is_none() && ancestor_param.description.is_some()
158+
}
159+
48160
/// Enrich a child method with docblock information from an ancestor method.
49161
///
50162
/// Propagates return types, parameter types, descriptions, template
@@ -110,10 +222,17 @@ pub(crate) fn enrich_method_from_ancestor(existing: &mut MethodInfo, ancestor: &
110222
// This follows PHPStan's `PhpDocInheritanceResolver`: for
111223
// `__construct` the positional parameter name list falls back to
112224
// the child's own names, so only same-named parameters inherit.
225+
// The would-change pre-check keeps the shared parameter list intact
226+
// (no copy-on-write) when there is nothing to propagate.
113227
if existing.name == "__construct" {
114-
enrich_constructor_parameters_by_name(&mut existing.parameters, &ancestor.parameters);
115-
} else {
116-
enrich_parameters_from_ancestor(&mut existing.parameters, &ancestor.parameters);
228+
if constructor_parameters_would_change(&existing.parameters, &ancestor.parameters) {
229+
enrich_constructor_parameters_by_name(
230+
existing.parameters.make_mut(),
231+
&ancestor.parameters,
232+
);
233+
}
234+
} else if positional_parameters_would_change(&existing.parameters, &ancestor.parameters) {
235+
enrich_parameters_from_ancestor(existing.parameters.make_mut(), &ancestor.parameters);
117236
}
118237

119238
// ── Descriptions ────────────────────────────────────────────

src/inheritance/generics.rs

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
//! and rewrites method/property signatures accordingly.
77
88
use std::collections::HashMap;
9-
use std::sync::Arc;
109

1110
use crate::atom::Atom;
1211
use crate::php_type::PhpType;
@@ -25,9 +24,20 @@ pub(crate) fn apply_substitution_to_method(
2524
if let Some(ref mut cond) = method.conditional_return {
2625
apply_substitution_to_conditional(cond, subs);
2726
}
28-
for param in &mut method.parameters {
29-
if let Some(ref mut hint) = param.type_hint {
30-
*hint = hint.substitute(subs);
27+
// Only copy-on-write the shared parameter list when a parameter
28+
// actually references a substituted name — substitution usually
29+
// rewrites just the return type.
30+
let keys: Vec<String> = subs.keys().cloned().collect();
31+
let any_param_referenced = method.parameters.iter().any(|p| {
32+
p.type_hint
33+
.as_ref()
34+
.is_some_and(|h| h.references_any_template_param(&keys))
35+
});
36+
if any_param_referenced {
37+
for param in method.parameters.make_mut() {
38+
if let Some(ref mut hint) = param.type_hint {
39+
*hint = hint.substitute(subs);
40+
}
3141
}
3242
}
3343
}
@@ -353,11 +363,41 @@ pub(crate) fn apply_generic_args(class: &ClassInfo, type_args: &[PhpType]) -> Cl
353363
}
354364

355365
let mut result = class.clone();
356-
for method in result.methods.make_mut() {
357-
apply_substitution_to_method(Arc::make_mut(method), &subs);
366+
let sub_keys: Vec<String> = subs.keys().cloned().collect();
367+
368+
// Only copy-on-write the member vectors (and the individual members)
369+
// that actually reference a substituted template parameter. A
370+
// generic variant like `Builder<User>` shares every method that does
371+
// not mention `TModel` with the base resolution instead of
372+
// deep-cloning the entire member set per instantiation.
373+
if result
374+
.methods
375+
.iter()
376+
.any(|m| method_references_params(m, &sub_keys))
377+
{
378+
let fp = crate::virtual_members::TransformFingerprint::new(Some(&subs), None, 0);
379+
for method in result.methods.make_mut() {
380+
if method_references_params(method, &sub_keys) {
381+
let transformed =
382+
crate::virtual_members::intern_transformed_method(method, fp, || {
383+
let mut m = (**method).clone();
384+
apply_substitution_to_method(&mut m, &subs);
385+
m
386+
});
387+
*method = transformed;
388+
}
389+
}
358390
}
359-
for property in result.properties.make_mut() {
360-
apply_substitution_to_property(property, &subs);
391+
if result
392+
.properties
393+
.iter()
394+
.any(|p| property_references_params(p, &sub_keys))
395+
{
396+
for property in result.properties.make_mut() {
397+
if property_references_params(property, &sub_keys) {
398+
apply_substitution_to_property(property, &subs);
399+
}
400+
}
361401
}
362402

363403
// Substitute template params in generic annotations so that

0 commit comments

Comments
 (0)