Skip to content

Commit f3c416c

Browse files
committed
Fix incorrect method resolution and crash
1 parent c565dba commit f3c416c

6 files changed

Lines changed: 377 additions & 21 deletions

File tree

src/completion/handler.rs

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
/// are also housed here because they are exclusively used by the
1111
/// completion handler.
1212
use std::collections::HashMap;
13+
use std::panic;
1314

1415
use tower_lsp::jsonrpc::Result;
1516
use tower_lsp::lsp_types::*;
@@ -363,22 +364,35 @@ impl Backend {
363364
let suppress =
364365
target.subject == "static" && current_class.is_some_and(|cc| cc.is_final);
365366

366-
let candidates = if suppress {
367-
vec![]
368-
} else {
369-
Self::resolve_target_classes(
370-
&target.subject,
371-
target.access_kind,
372-
current_class,
373-
&classes,
374-
&content,
375-
cursor_offset.unwrap_or(0),
376-
&class_loader,
377-
Some(&function_loader),
378-
)
379-
};
367+
// Wrap resolution + inheritance merging in catch_unwind so
368+
// that a stack overflow (e.g. from deep trait/inheritance
369+
// resolution when the subject is a call expression like
370+
// `collect($x)->`) doesn't crash the LSP server process.
371+
// The variable-resolution path already has its own
372+
// catch_unwind, but the direct call-expression path
373+
// (resolve_call_return_types → type_hint_to_classes →
374+
// class_loader → find_or_load_class → parse_php →
375+
// resolve_class_with_inheritance) does not.
376+
let member_items_result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
377+
let candidates = if suppress {
378+
vec![]
379+
} else {
380+
Self::resolve_target_classes(
381+
&target.subject,
382+
target.access_kind,
383+
current_class,
384+
&classes,
385+
&content,
386+
cursor_offset.unwrap_or(0),
387+
&class_loader,
388+
Some(&function_loader),
389+
)
390+
};
391+
392+
if candidates.is_empty() {
393+
return vec![];
394+
}
380395

381-
if !candidates.is_empty() {
382396
// `parent::`, `self::`, and `static::` are syntactically
383397
// `::` but semantically different from external static
384398
// access: they show both static and instance members
@@ -449,9 +463,20 @@ impl Backend {
449463
}
450464
}
451465
}
452-
if !all_items.is_empty() {
466+
all_items
467+
}));
468+
469+
match member_items_result {
470+
Ok(all_items) if !all_items.is_empty() => {
453471
return Ok(Some(CompletionResponse::Array(all_items)));
454472
}
473+
Err(_) => {
474+
log::error!(
475+
"PHPantomLSP: panic during member-access completion for '{}'",
476+
target.subject
477+
);
478+
}
479+
_ => {}
455480
}
456481
}
457482

src/definition/member.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,19 @@ impl Backend {
177177

178178
// ─── Member Access Context Extraction ───────────────────────────────────
179179

180+
/// Check whether the cursor is on the right-hand side of a member
181+
/// access operator (`->`, `?->`, or `::`).
182+
///
183+
/// Returns `true` when the word under the cursor is preceded by one
184+
/// of these operators — meaning the word is a member name, NOT a
185+
/// standalone function / class / constant. This is used by
186+
/// [`resolve_definition`](super::resolve) to prevent falling through
187+
/// to standalone symbol resolution when member resolution fails
188+
/// (e.g. because the owning class couldn't be determined).
189+
pub(super) fn is_member_access_context(content: &str, position: Position) -> bool {
190+
Self::extract_member_access_context(content, position).is_some()
191+
}
192+
180193
/// Detect the access operator (`::`, `->`, `?->`) immediately before the
181194
/// word under the cursor and extract the subject to its left.
182195
///

src/definition/resolve.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,28 @@ impl Backend {
6262
return None;
6363
}
6464

65-
// ── NEW: Try member access resolution (::, ->, ?->) ──
65+
// ── Member access resolution (::, ->, ?->) ──
6666
// If the cursor is on a member name (right side of an operator),
6767
// resolve the owning class and jump to the member declaration.
68+
//
69+
// When a member-access operator IS detected but resolution fails
70+
// (e.g. the owning class couldn't be determined because a helper
71+
// function like `collect()` isn't indexed), we must return early
72+
// so that the member name (e.g. `map`) is NOT misinterpreted as
73+
// a standalone function / class / constant. Without this guard,
74+
// `collect($x)->map(` would fall through and resolve `map` to a
75+
// global `map()` helper function — or even crash while trying.
76+
let is_member_access = Self::is_member_access_context(content, position);
6877
if let Some(location) = self.resolve_member_definition(uri, content, position, &word) {
6978
return Some(location);
7079
}
80+
if is_member_access {
81+
// The cursor is on the RHS of `->`, `?->`, or `::` but we
82+
// couldn't resolve the owning class. Don't fall through to
83+
// standalone symbol resolution — there is no standalone
84+
// symbol named `map`, `getName`, etc.
85+
return None;
86+
}
7187

7288
// ── Handle `self`, `static`, `parent` keywords ──
7389
// When the cursor is on one of these keywords (e.g. `new self()`,

src/server.rs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -238,10 +238,30 @@ impl LanguageServer for Backend {
238238
None
239239
};
240240

241-
if let Some(content) = content
242-
&& let Some(location) = self.resolve_definition(&uri, &content, position)
243-
{
244-
return Ok(Some(GotoDefinitionResponse::Scalar(location)));
241+
if let Some(content) = content {
242+
// Wrap in catch_unwind so that a stack overflow (e.g. from
243+
// deep trait/inheritance resolution when the subject is a
244+
// call expression like `collect($x)->map(`) doesn't crash
245+
// the LSP server process.
246+
let uri_owned = uri.clone();
247+
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
248+
self.resolve_definition(&uri_owned, &content, position)
249+
}));
250+
251+
match result {
252+
Ok(Some(location)) => {
253+
return Ok(Some(GotoDefinitionResponse::Scalar(location)));
254+
}
255+
Ok(None) => {}
256+
Err(_) => {
257+
log::error!(
258+
"PHPantomLSP: panic during goto_definition at {}:{}:{}",
259+
uri,
260+
position.line,
261+
position.character
262+
);
263+
}
264+
}
245265
}
246266

247267
Ok(None)

tests/completion_methods.rs

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,143 @@ use common::create_test_backend;
44
use tower_lsp::LanguageServer;
55
use tower_lsp::lsp_types::*;
66

7+
/// Regression test: completion on `collect($var)->` resolves Collection members.
8+
///
9+
/// The `collect()` helper returns `Collection` and the cursor is right
10+
/// after `->` — the subject is `collect($paymentOptions)` which is a
11+
/// standalone function call whose return type is a class.
12+
#[tokio::test]
13+
async fn test_completion_on_function_call_arrow() {
14+
let backend = create_test_backend();
15+
16+
let uri = Url::parse("file:///collect_map.php").unwrap();
17+
let text = r#"<?php
18+
19+
class Collection {
20+
/** @return static */
21+
public function map(callable $callback): static {}
22+
23+
/** @return static */
24+
public function values(): static {}
25+
26+
/** @return mixed */
27+
public function first(): mixed {}
28+
}
29+
30+
/**
31+
* @return Collection
32+
*/
33+
function collect($value = []): Collection
34+
{
35+
return new Collection($value);
36+
}
37+
38+
class PaymentOptionLocale {
39+
public bool $tokens_enabled;
40+
}
41+
42+
class PaymentService {
43+
public function getOptions(array $paymentOptions): void {
44+
$formattedOptions = collect($paymentOptions)->map(function (PaymentOptionLocale $optionLocale) {
45+
return [
46+
'tokens_enabled' => $optionLocale->tokens_enabled,
47+
];
48+
})->values();
49+
$formattedOptions->
50+
}
51+
}
52+
"#;
53+
54+
let open_params = DidOpenTextDocumentParams {
55+
text_document: TextDocumentItem {
56+
uri: uri.clone(),
57+
language_id: "php".to_string(),
58+
version: 1,
59+
text: text.to_string(),
60+
},
61+
};
62+
backend.did_open(open_params).await;
63+
64+
// ── Test 1: completion after `$formattedOptions->` ──
65+
let target_line = text
66+
.lines()
67+
.enumerate()
68+
.find(|(_, l)| l.trim() == "$formattedOptions->")
69+
.map(|(i, _)| i)
70+
.expect("should find $formattedOptions-> line");
71+
72+
let completion_params = CompletionParams {
73+
text_document_position: TextDocumentPositionParams {
74+
text_document: TextDocumentIdentifier { uri: uri.clone() },
75+
position: Position {
76+
line: target_line as u32,
77+
character: 28,
78+
},
79+
},
80+
work_done_progress_params: WorkDoneProgressParams::default(),
81+
partial_result_params: PartialResultParams::default(),
82+
context: None,
83+
};
84+
85+
let result = backend.completion(completion_params).await.unwrap();
86+
let items = match result {
87+
Some(CompletionResponse::Array(items)) => items,
88+
Some(CompletionResponse::List(list)) => list.items,
89+
None => vec![],
90+
};
91+
92+
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
93+
assert!(
94+
labels.iter().any(|l| l.starts_with("map")),
95+
"Expected 'map' in completions for Collection, got: {:?}",
96+
labels
97+
);
98+
assert!(
99+
labels.iter().any(|l| l.starts_with("values")),
100+
"Expected 'values' in completions for Collection, got: {:?}",
101+
labels
102+
);
103+
104+
// ── Test 2: completion after `collect($paymentOptions)->` ──
105+
// The cursor is right after `->` before `map` on the chained call line.
106+
let chain_line = text
107+
.lines()
108+
.enumerate()
109+
.find(|(_, l)| l.contains("collect($paymentOptions)->map("))
110+
.map(|(i, _)| i)
111+
.expect("should find collect()->map( line");
112+
113+
let chain_line_text = text.lines().nth(chain_line).unwrap();
114+
let arrow_col = chain_line_text.find("->map(").unwrap() as u32 + 2; // after `->`
115+
116+
let completion_params2 = CompletionParams {
117+
text_document_position: TextDocumentPositionParams {
118+
text_document: TextDocumentIdentifier { uri },
119+
position: Position {
120+
line: chain_line as u32,
121+
character: arrow_col + 3, // after `->map`
122+
},
123+
},
124+
work_done_progress_params: WorkDoneProgressParams::default(),
125+
partial_result_params: PartialResultParams::default(),
126+
context: None,
127+
};
128+
129+
// Must not crash and should offer Collection members
130+
let result2 = backend.completion(completion_params2).await.unwrap();
131+
let items2 = match result2 {
132+
Some(CompletionResponse::Array(items)) => items,
133+
Some(CompletionResponse::List(list)) => list.items,
134+
None => vec![],
135+
};
136+
let labels2: Vec<&str> = items2.iter().map(|i| i.label.as_str()).collect();
137+
assert!(
138+
labels2.iter().any(|l| l.starts_with("map")),
139+
"Expected 'map' in completions on chained collect()->, got: {:?}",
140+
labels2
141+
);
142+
}
143+
7144
// ─── Method Insert Text with Parameters ─────────────────────────────────────
8145

9146
#[tokio::test]

0 commit comments

Comments
 (0)