Skip to content

Commit e23d430

Browse files
committed
Add support for traits
1 parent afcb9a0 commit e23d430

5 files changed

Lines changed: 2011 additions & 19 deletions

File tree

src/completion/resolver.rs

Lines changed: 83 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -132,16 +132,17 @@ impl Backend {
132132
// method calls (`$this->getService()`),
133133
// and static method calls (`ClassName::make()`).
134134
if subject.ends_with(')')
135-
&& let Some((call_body, args_text)) = split_call_subject(subject) {
136-
return Self::resolve_call_return_types(
137-
call_body,
138-
args_text,
139-
current_class,
140-
all_classes,
141-
class_loader,
142-
function_loader,
143-
);
144-
}
135+
&& let Some((call_body, args_text)) = split_call_subject(subject)
136+
{
137+
return Self::resolve_call_return_types(
138+
call_body,
139+
args_text,
140+
current_class,
141+
all_classes,
142+
class_loader,
143+
function_loader,
144+
);
145+
}
145146

146147
// ── Property-chain: $this->prop or $this?->prop ──
147148
if let Some(prop_name) = subject
@@ -1101,6 +1102,13 @@ impl Backend {
11011102
) -> ClassInfo {
11021103
let mut merged = class.clone();
11031104

1105+
// 1. Merge traits used by this class.
1106+
// PHP precedence: class methods > trait methods > inherited methods.
1107+
// Since `merged` already contains the class's own members, we only
1108+
// add trait members that don't collide with existing ones.
1109+
Self::merge_traits_into(&mut merged, &class.used_traits, class_loader, 0);
1110+
1111+
// 2. Walk up the `extends` chain and merge parent members.
11041112
let mut current = class.clone();
11051113
let mut depth = 0;
11061114
const MAX_DEPTH: u32 = 20;
@@ -1117,6 +1125,10 @@ impl Backend {
11171125
break;
11181126
};
11191127

1128+
// Merge traits used by the parent class as well, so that
1129+
// grandparent-level trait members are visible.
1130+
Self::merge_traits_into(&mut merged, &parent.used_traits, class_loader, 0);
1131+
11201132
// Merge parent methods — skip private, skip if child already has one with same name
11211133
for method in &parent.methods {
11221134
if method.visibility == Visibility::Private {
@@ -1155,6 +1167,67 @@ impl Backend {
11551167

11561168
merged
11571169
}
1170+
1171+
/// Recursively merge members from the given traits into `merged`.
1172+
///
1173+
/// Traits can themselves `use` other traits (composition), so this
1174+
/// method recurses up to `MAX_TRAIT_DEPTH` levels. Members that
1175+
/// already exist in `merged` (by name) are skipped — this naturally
1176+
/// implements the PHP precedence rule where the current class's own
1177+
/// members win over trait members, and earlier-listed traits win
1178+
/// over later ones.
1179+
///
1180+
/// Private trait members *are* merged (unlike parent class private
1181+
/// members), because PHP copies trait members into the using class
1182+
/// regardless of visibility.
1183+
fn merge_traits_into(
1184+
merged: &mut ClassInfo,
1185+
trait_names: &[String],
1186+
class_loader: &dyn Fn(&str) -> Option<ClassInfo>,
1187+
depth: u32,
1188+
) {
1189+
const MAX_TRAIT_DEPTH: u32 = 20;
1190+
if depth > MAX_TRAIT_DEPTH {
1191+
return;
1192+
}
1193+
1194+
for trait_name in trait_names {
1195+
let trait_info = if let Some(t) = class_loader(trait_name) {
1196+
t
1197+
} else {
1198+
continue;
1199+
};
1200+
1201+
// Recursively merge traits used by this trait (trait composition).
1202+
if !trait_info.used_traits.is_empty() {
1203+
Self::merge_traits_into(merged, &trait_info.used_traits, class_loader, depth + 1);
1204+
}
1205+
1206+
// Merge trait methods — skip if already present
1207+
for method in &trait_info.methods {
1208+
if merged.methods.iter().any(|m| m.name == method.name) {
1209+
continue;
1210+
}
1211+
merged.methods.push(method.clone());
1212+
}
1213+
1214+
// Merge trait properties
1215+
for property in &trait_info.properties {
1216+
if merged.properties.iter().any(|p| p.name == property.name) {
1217+
continue;
1218+
}
1219+
merged.properties.push(property.clone());
1220+
}
1221+
1222+
// Merge trait constants
1223+
for constant in &trait_info.constants {
1224+
if merged.constants.iter().any(|c| c.name == constant.name) {
1225+
continue;
1226+
}
1227+
merged.constants.push(constant.clone());
1228+
}
1229+
}
1230+
}
11581231
}
11591232

11601233
// ─── PHPStan Conditional Return Type Resolution ─────────────────────────────

src/definition/resolve.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,13 @@ impl Backend {
683683
return Some(class.clone());
684684
}
685685

686+
// Check traits used by this class.
687+
if let Some(found) =
688+
Self::find_declaring_in_traits(&class.used_traits, member_name, class_loader, 0)
689+
{
690+
return Some(found);
691+
}
692+
686693
// Walk up the parent chain.
687694
let mut current = class.clone();
688695
for _ in 0..MAX_DEPTH {
@@ -691,12 +698,56 @@ impl Backend {
691698
if Self::classify_member(&parent, member_name).is_some() {
692699
return Some(parent);
693700
}
701+
// Check traits used by the parent class.
702+
if let Some(found) =
703+
Self::find_declaring_in_traits(&parent.used_traits, member_name, class_loader, 0)
704+
{
705+
return Some(found);
706+
}
694707
current = parent;
695708
}
696709

697710
None
698711
}
699712

713+
/// Search through a list of trait names for one that declares `member_name`.
714+
///
715+
/// Traits can themselves `use` other traits, so this recurses up to a
716+
/// depth limit to handle trait composition.
717+
fn find_declaring_in_traits(
718+
trait_names: &[String],
719+
member_name: &str,
720+
class_loader: &dyn Fn(&str) -> Option<ClassInfo>,
721+
depth: usize,
722+
) -> Option<ClassInfo> {
723+
const MAX_TRAIT_DEPTH: usize = 20;
724+
if depth > MAX_TRAIT_DEPTH {
725+
return None;
726+
}
727+
728+
for trait_name in trait_names {
729+
let trait_info = if let Some(t) = class_loader(trait_name) {
730+
t
731+
} else {
732+
continue;
733+
};
734+
if Self::classify_member(&trait_info, member_name).is_some() {
735+
return Some(trait_info);
736+
}
737+
// Recurse into traits used by this trait.
738+
if let Some(found) = Self::find_declaring_in_traits(
739+
&trait_info.used_traits,
740+
member_name,
741+
class_loader,
742+
depth + 1,
743+
) {
744+
return Some(found);
745+
}
746+
}
747+
748+
None
749+
}
750+
700751
// ─── File & Position Lookup ─────────────────────────────────────────────
701752

702753
/// Find the file URI and content for the file that contains a given class.

src/parser.rs

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,7 @@ impl Backend {
517517
.as_ref()
518518
.and_then(|ext| ext.types.first().map(|ident| ident.value().to_string()));
519519

520-
let (methods, properties, constants) =
520+
let (methods, properties, constants, used_traits) =
521521
Self::extract_class_like_members(class.members.iter(), doc_ctx);
522522

523523
let start_offset = class.left_brace.start.offset;
@@ -531,6 +531,7 @@ impl Backend {
531531
start_offset,
532532
end_offset,
533533
parent_class,
534+
used_traits,
534535
});
535536
}
536537
Statement::Interface(iface) => {
@@ -543,7 +544,7 @@ impl Backend {
543544
.as_ref()
544545
.and_then(|ext| ext.types.first().map(|ident| ident.value().to_string()));
545546

546-
let (methods, properties, constants) =
547+
let (methods, properties, constants, used_traits) =
547548
Self::extract_class_like_members(iface.members.iter(), doc_ctx);
548549

549550
let start_offset = iface.left_brace.start.offset;
@@ -557,6 +558,27 @@ impl Backend {
557558
start_offset,
558559
end_offset,
559560
parent_class,
561+
used_traits,
562+
});
563+
}
564+
Statement::Trait(trait_def) => {
565+
let trait_name = trait_def.name.value.to_string();
566+
567+
let (methods, properties, constants, used_traits) =
568+
Self::extract_class_like_members(trait_def.members.iter(), doc_ctx);
569+
570+
let start_offset = trait_def.left_brace.start.offset;
571+
let end_offset = trait_def.right_brace.end.offset;
572+
573+
classes.push(ClassInfo {
574+
name: trait_name,
575+
methods,
576+
properties,
577+
constants,
578+
start_offset,
579+
end_offset,
580+
parent_class: None,
581+
used_traits,
560582
});
561583
}
562584
Statement::Namespace(namespace) => {
@@ -571,20 +593,28 @@ impl Backend {
571593
}
572594
}
573595

574-
/// Extract methods, properties, and constants from class-like members.
596+
/// Extract methods, properties, constants, and used trait names from
597+
/// class-like members.
575598
///
576-
/// This is shared between `Statement::Class` and `Statement::Interface`
577-
/// since both use the same `ClassLikeMember` representation.
599+
/// This is shared between `Statement::Class`, `Statement::Interface`,
600+
/// and `Statement::Trait` since all use the same `ClassLikeMember`
601+
/// representation.
578602
///
579603
/// When `doc_ctx` is provided, PHPDoc `@return` and `@var` tags are used
580604
/// to refine (or supply) type information for methods and properties.
581605
fn extract_class_like_members<'a>(
582606
members: impl Iterator<Item = &'a ClassLikeMember<'a>>,
583607
doc_ctx: Option<&DocblockCtx<'a>>,
584-
) -> (Vec<MethodInfo>, Vec<PropertyInfo>, Vec<ConstantInfo>) {
608+
) -> (
609+
Vec<MethodInfo>,
610+
Vec<PropertyInfo>,
611+
Vec<ConstantInfo>,
612+
Vec<String>,
613+
) {
585614
let mut methods = Vec::new();
586615
let mut properties = Vec::new();
587616
let mut constants = Vec::new();
617+
let mut used_traits = Vec::new();
588618

589619
for member in members {
590620
match member {
@@ -675,11 +705,16 @@ impl Backend {
675705
});
676706
}
677707
}
708+
ClassLikeMember::TraitUse(trait_use) => {
709+
for trait_name_ident in trait_use.trait_names.iter() {
710+
used_traits.push(trait_name_ident.value().to_string());
711+
}
712+
}
678713
_ => {}
679714
}
680715
}
681716

682-
(methods, properties, constants)
717+
(methods, properties, constants, used_traits)
683718
}
684719

685720
/// Update the ast_map, use_map, and namespace_map for a given file URI
@@ -718,7 +753,7 @@ impl Backend {
718753
Statement::Use(use_stmt) => {
719754
Self::extract_use_items(&use_stmt.items, &mut use_map);
720755
}
721-
Statement::Class(_) | Statement::Interface(_) => {
756+
Statement::Class(_) | Statement::Interface(_) | Statement::Trait(_) => {
722757
Self::extract_classes_from_statements(
723758
std::iter::once(inner),
724759
&mut classes,
@@ -741,7 +776,7 @@ impl Backend {
741776
}
742777
}
743778
}
744-
Statement::Class(_) | Statement::Interface(_) => {
779+
Statement::Class(_) | Statement::Interface(_) | Statement::Trait(_) => {
745780
Self::extract_classes_from_statements(
746781
std::iter::once(statement),
747782
&mut classes,
@@ -834,6 +869,12 @@ impl Backend {
834869
let resolved = Self::resolve_name(parent, use_map, namespace);
835870
class.parent_class = Some(resolved);
836871
}
872+
// Resolve trait names to fully-qualified names
873+
class.used_traits = class
874+
.used_traits
875+
.iter()
876+
.map(|t| Self::resolve_name(t, use_map, namespace))
877+
.collect();
837878
}
838879
}
839880

src/types.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,4 +192,7 @@ pub struct ClassInfo {
192192
/// The parent class name from the `extends` clause, if any.
193193
/// This is the raw name as written in source (e.g. "BaseClass", "Foo\\Bar").
194194
pub parent_class: Option<String>,
195+
/// Trait names used by this class via `use TraitName;` statements.
196+
/// These are resolved to fully-qualified names during post-processing.
197+
pub used_traits: Vec<String>,
195198
}

0 commit comments

Comments
 (0)