Skip to content

Commit 453d574

Browse files
committed
Fix property vs method resolution
1 parent e23d430 commit 453d574

3 files changed

Lines changed: 302 additions & 16 deletions

File tree

src/completion/resolver.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1243,6 +1243,7 @@ impl Backend {
12431243
/// a null default).
12441244
/// - `is SomeType`: not statically resolvable from AST; falls through
12451245
/// to the else branch.
1246+
///
12461247
/// Split a call-expression subject into the call body and any textual
12471248
/// arguments. Handles both `"app()"` → `("app", "")` and
12481249
/// `"app(A::class)"` → `("app", "A::class")`.

src/definition/resolve.rs

Lines changed: 120 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,19 @@ enum MemberKind {
3232
Constant,
3333
}
3434

35+
/// Hint about whether the member access looks like a method call or a property
36+
/// access. Used to disambiguate when a class has both a method and a property
37+
/// with the same name (e.g. `id()` method vs `$id` property).
38+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39+
enum MemberAccessHint {
40+
/// Followed by `(` — looks like a method call.
41+
MethodCall,
42+
/// No `(` after the name — looks like a property / constant access.
43+
PropertyAccess,
44+
/// Cannot determine (fallback to original order).
45+
Unknown,
46+
}
47+
3548
impl Backend {
3649
/// Handle a "go to definition" request.
3750
///
@@ -337,6 +350,9 @@ impl Backend {
337350
return None;
338351
}
339352

353+
// Determine whether this looks like a method call or property access.
354+
let access_hint = Self::detect_member_access_hint(content, position, member_name);
355+
340356
// 4. Try each candidate class and pick the first one where the
341357
// member actually exists (directly or via inheritance).
342358
for target_class in &candidates {
@@ -345,10 +361,11 @@ impl Backend {
345361
.unwrap_or_else(|| target_class.clone());
346362

347363
// Check that the member is actually present on the declaring class.
348-
let member_kind = match Self::classify_member(&declaring_class, member_name) {
349-
Some(k) => k,
350-
None => continue, // member not on this candidate, try next
351-
};
364+
let member_kind =
365+
match Self::classify_member(&declaring_class, member_name, access_hint) {
366+
Some(k) => k,
367+
None => continue, // member not on this candidate, try next
368+
};
352369

353370
// Locate the file that contains the declaring class.
354371
if let Some((class_uri, class_content)) =
@@ -374,7 +391,7 @@ impl Backend {
374391
let declaring_class = Self::find_declaring_class(target_class, member_name, &class_loader)
375392
.unwrap_or_else(|| target_class.clone());
376393

377-
let member_kind = Self::classify_member(&declaring_class, member_name)?;
394+
let member_kind = Self::classify_member(&declaring_class, member_name, access_hint)?;
378395

379396
let (class_uri, class_content) =
380397
self.find_class_file_content(&declaring_class.name, uri, content)?;
@@ -653,17 +670,103 @@ impl Backend {
653670
/// checking the class's parsed information.
654671
///
655672
/// Returns `None` if the member is not found in the class.
656-
fn classify_member(class: &ClassInfo, member_name: &str) -> Option<MemberKind> {
657-
if class.methods.iter().any(|m| m.name == member_name) {
658-
return Some(MemberKind::Method);
673+
fn classify_member(
674+
class: &ClassInfo,
675+
member_name: &str,
676+
hint: MemberAccessHint,
677+
) -> Option<MemberKind> {
678+
let has_method = class.methods.iter().any(|m| m.name == member_name);
679+
let has_property = class.properties.iter().any(|p| p.name == member_name);
680+
let has_constant = class.constants.iter().any(|c| c.name == member_name);
681+
682+
match hint {
683+
MemberAccessHint::PropertyAccess => {
684+
// Prefer property/constant over method when there's no `()`.
685+
if has_property {
686+
return Some(MemberKind::Property);
687+
}
688+
if has_constant {
689+
return Some(MemberKind::Constant);
690+
}
691+
if has_method {
692+
return Some(MemberKind::Method);
693+
}
694+
}
695+
MemberAccessHint::MethodCall => {
696+
// Prefer method when followed by `()`.
697+
if has_method {
698+
return Some(MemberKind::Method);
699+
}
700+
if has_property {
701+
return Some(MemberKind::Property);
702+
}
703+
if has_constant {
704+
return Some(MemberKind::Constant);
705+
}
706+
}
707+
MemberAccessHint::Unknown => {
708+
// Default order: method, property, constant.
709+
if has_method {
710+
return Some(MemberKind::Method);
711+
}
712+
if has_property {
713+
return Some(MemberKind::Property);
714+
}
715+
if has_constant {
716+
return Some(MemberKind::Constant);
717+
}
718+
}
719+
}
720+
None
721+
}
722+
723+
/// Determine whether the member name at the given position is followed by
724+
/// `(` (indicating a method call) or not (indicating property / constant
725+
/// access).
726+
fn detect_member_access_hint(
727+
content: &str,
728+
position: Position,
729+
member_name: &str,
730+
) -> MemberAccessHint {
731+
let lines: Vec<&str> = content.lines().collect();
732+
let line = match lines.get(position.line as usize) {
733+
Some(l) => *l,
734+
None => return MemberAccessHint::Unknown,
735+
};
736+
let chars: Vec<char> = line.chars().collect();
737+
let col = (position.character as usize).min(chars.len());
738+
739+
// Find the end of the member name by walking right from the cursor.
740+
let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
741+
742+
let mut end = col;
743+
// If cursor is on a word char, walk right to end of word.
744+
if end < chars.len() && is_word_char(chars[end]) {
745+
while end < chars.len() && is_word_char(chars[end]) {
746+
end += 1;
747+
}
748+
} else if end > 0 && is_word_char(chars[end - 1]) {
749+
// Cursor is just past the word; `end` is already correct.
750+
} else {
751+
// Try to find the member name by searching forward from col.
752+
if let Some(idx) = line[col..].find(member_name) {
753+
end = col + idx + member_name.len();
754+
} else {
755+
return MemberAccessHint::Unknown;
756+
}
659757
}
660-
if class.properties.iter().any(|p| p.name == member_name) {
661-
return Some(MemberKind::Property);
758+
759+
// Skip whitespace after the word.
760+
let mut i = end;
761+
while i < chars.len() && chars[i].is_whitespace() {
762+
i += 1;
662763
}
663-
if class.constants.iter().any(|c| c.name == member_name) {
664-
return Some(MemberKind::Constant);
764+
765+
if i < chars.len() && chars[i] == '(' {
766+
MemberAccessHint::MethodCall
767+
} else {
768+
MemberAccessHint::PropertyAccess
665769
}
666-
None
667770
}
668771

669772
/// Walk up the inheritance chain to find the class that actually declares
@@ -679,7 +782,7 @@ impl Backend {
679782
const MAX_DEPTH: usize = 20;
680783

681784
// Check if this class directly declares the member.
682-
if Self::classify_member(class, member_name).is_some() {
785+
if Self::classify_member(class, member_name, MemberAccessHint::Unknown).is_some() {
683786
return Some(class.clone());
684787
}
685788

@@ -695,7 +798,7 @@ impl Backend {
695798
for _ in 0..MAX_DEPTH {
696799
let parent_name = current.parent_class.as_ref()?;
697800
let parent = class_loader(parent_name)?;
698-
if Self::classify_member(&parent, member_name).is_some() {
801+
if Self::classify_member(&parent, member_name, MemberAccessHint::Unknown).is_some() {
699802
return Some(parent);
700803
}
701804
// Check traits used by the parent class.
@@ -731,7 +834,8 @@ impl Backend {
731834
} else {
732835
continue;
733836
};
734-
if Self::classify_member(&trait_info, member_name).is_some() {
837+
if Self::classify_member(&trait_info, member_name, MemberAccessHint::Unknown).is_some()
838+
{
735839
return Some(trait_info);
736840
}
737841
// Recurse into traits used by this trait.

tests/definition.rs

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4195,3 +4195,184 @@ async fn test_goto_definition_union_return_type_cross_file() {
41954195
other => panic!("Expected Scalar location, got: {:?}", other),
41964196
}
41974197
}
4198+
4199+
// ─── Property vs Method Disambiguation ──────────────────────────────────────
4200+
4201+
/// When a class has both a property `$id` and a method `id()`, goto-definition
4202+
/// on `$user->id` (no parentheses) should navigate to the *property*, not the
4203+
/// method.
4204+
#[tokio::test]
4205+
async fn test_goto_definition_property_preferred_over_method_without_parens() {
4206+
let backend = create_test_backend();
4207+
4208+
let uri = Url::parse("file:///test.php").unwrap();
4209+
// line
4210+
let text = concat!(
4211+
"<?php\n", // 0
4212+
"class User {\n", // 1
4213+
" public int $id;\n", // 2 ← property declaration
4214+
" public string $name;\n", // 3
4215+
"\n", // 4
4216+
" public function id(): int {\n", // 5 ← method declaration
4217+
" return $this->id;\n", // 6
4218+
" }\n", // 7
4219+
"\n", // 8
4220+
" public function test(): void {\n", // 9
4221+
" $user = new User();\n", // 10
4222+
" $val = $user->id;\n", // 11 ← property access (no parens)
4223+
" $val2 = $user->id();\n", // 12 ← method call (with parens)
4224+
" }\n", // 13
4225+
"}\n", // 14
4226+
);
4227+
4228+
let open_params = DidOpenTextDocumentParams {
4229+
text_document: TextDocumentItem {
4230+
uri: uri.clone(),
4231+
language_id: "php".to_string(),
4232+
version: 1,
4233+
text: text.to_string(),
4234+
},
4235+
};
4236+
backend.did_open(open_params).await;
4237+
4238+
// ── Case 1: `$user->id` (no parens) → should go to the $id property on line 2
4239+
let params = GotoDefinitionParams {
4240+
text_document_position_params: TextDocumentPositionParams {
4241+
text_document: TextDocumentIdentifier { uri: uri.clone() },
4242+
position: Position {
4243+
line: 11,
4244+
character: 24, // on "id" in `$user->id`
4245+
},
4246+
},
4247+
work_done_progress_params: WorkDoneProgressParams::default(),
4248+
partial_result_params: PartialResultParams::default(),
4249+
};
4250+
4251+
let result = backend.goto_definition(params).await.unwrap();
4252+
assert!(
4253+
result.is_some(),
4254+
"Should resolve $user->id to the property declaration"
4255+
);
4256+
4257+
match result.unwrap() {
4258+
GotoDefinitionResponse::Scalar(location) => {
4259+
assert_eq!(location.uri, uri);
4260+
assert_eq!(
4261+
location.range.start.line, 2,
4262+
"$user->id (no parens) should go to the $id property on line 2, not the id() method on line 5"
4263+
);
4264+
}
4265+
other => panic!("Expected Scalar location, got: {:?}", other),
4266+
}
4267+
4268+
// ── Case 2: `$user->id()` (with parens) → should go to the id() method on line 5
4269+
let params = GotoDefinitionParams {
4270+
text_document_position_params: TextDocumentPositionParams {
4271+
text_document: TextDocumentIdentifier { uri: uri.clone() },
4272+
position: Position {
4273+
line: 12,
4274+
character: 25, // on "id" in `$user->id()`
4275+
},
4276+
},
4277+
work_done_progress_params: WorkDoneProgressParams::default(),
4278+
partial_result_params: PartialResultParams::default(),
4279+
};
4280+
4281+
let result = backend.goto_definition(params).await.unwrap();
4282+
assert!(
4283+
result.is_some(),
4284+
"Should resolve $user->id() to the method declaration"
4285+
);
4286+
4287+
match result.unwrap() {
4288+
GotoDefinitionResponse::Scalar(location) => {
4289+
assert_eq!(location.uri, uri);
4290+
assert_eq!(
4291+
location.range.start.line, 5,
4292+
"$user->id() (with parens) should go to the id() method on line 5, not the $id property on line 2"
4293+
);
4294+
}
4295+
other => panic!("Expected Scalar location, got: {:?}", other),
4296+
}
4297+
}
4298+
4299+
/// Same disambiguation but via `$this->` inside the class itself.
4300+
#[tokio::test]
4301+
async fn test_goto_definition_this_property_vs_method_disambiguation() {
4302+
let backend = create_test_backend();
4303+
4304+
let uri = Url::parse("file:///test.php").unwrap();
4305+
let text = concat!(
4306+
"<?php\n", // 0
4307+
"class Order {\n", // 1
4308+
" public float $total;\n", // 2 ← property
4309+
"\n", // 3
4310+
" public function total(): float {\n", // 4 ← method
4311+
" return $this->total;\n", // 5 ← property access
4312+
" }\n", // 6
4313+
"\n", // 7
4314+
" public function display(): void {\n", // 8
4315+
" echo $this->total;\n", // 9 ← property access
4316+
" echo $this->total();\n", // 10 ← method call
4317+
" }\n", // 11
4318+
"}\n", // 12
4319+
);
4320+
4321+
let open_params = DidOpenTextDocumentParams {
4322+
text_document: TextDocumentItem {
4323+
uri: uri.clone(),
4324+
language_id: "php".to_string(),
4325+
version: 1,
4326+
text: text.to_string(),
4327+
},
4328+
};
4329+
backend.did_open(open_params).await;
4330+
4331+
// `$this->total` on line 9 (no parens) → property on line 2
4332+
let params = GotoDefinitionParams {
4333+
text_document_position_params: TextDocumentPositionParams {
4334+
text_document: TextDocumentIdentifier { uri: uri.clone() },
4335+
position: Position {
4336+
line: 9,
4337+
character: 23, // on "total" in `$this->total`
4338+
},
4339+
},
4340+
work_done_progress_params: WorkDoneProgressParams::default(),
4341+
partial_result_params: PartialResultParams::default(),
4342+
};
4343+
4344+
let result = backend.goto_definition(params).await.unwrap();
4345+
match result.unwrap() {
4346+
GotoDefinitionResponse::Scalar(location) => {
4347+
assert_eq!(
4348+
location.range.start.line, 2,
4349+
"$this->total (no parens) should go to the $total property on line 2"
4350+
);
4351+
}
4352+
other => panic!("Expected Scalar location, got: {:?}", other),
4353+
}
4354+
4355+
// `$this->total()` on line 10 (with parens) → method on line 4
4356+
let params = GotoDefinitionParams {
4357+
text_document_position_params: TextDocumentPositionParams {
4358+
text_document: TextDocumentIdentifier { uri: uri.clone() },
4359+
position: Position {
4360+
line: 10,
4361+
character: 23, // on "total" in `$this->total()`
4362+
},
4363+
},
4364+
work_done_progress_params: WorkDoneProgressParams::default(),
4365+
partial_result_params: PartialResultParams::default(),
4366+
};
4367+
4368+
let result = backend.goto_definition(params).await.unwrap();
4369+
match result.unwrap() {
4370+
GotoDefinitionResponse::Scalar(location) => {
4371+
assert_eq!(
4372+
location.range.start.line, 4,
4373+
"$this->total() (with parens) should go to the total() method on line 4"
4374+
);
4375+
}
4376+
other => panic!("Expected Scalar location, got: {:?}", other),
4377+
}
4378+
}

0 commit comments

Comments
 (0)