Skip to content

Commit d6eb3a1

Browse files
committed
Support array element access
1 parent 894eb20 commit d6eb3a1

6 files changed

Lines changed: 741 additions & 5 deletions

File tree

example.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -670,3 +670,26 @@ function handleIntersection(User&Loggable $entity): void
670670
foreach ($admins as $admin) {
671671
$admin->grantPermission('x'); // $admin resolved to AdminUser via array<int, AdminUser>
672672
}
673+
674+
675+
// ── Array Access Element Types ──────────────────────────────────────────────
676+
677+
/** @var list<User> $users */
678+
$users = getUnknownValue();
679+
$users[0]->getEmail(); // element resolved to User via list<User>
680+
681+
/** @var User[] $members */
682+
$members = getUnknownValue();
683+
$members[0]->getName(); // element resolved to User via User[]
684+
685+
/** @var array<int, AdminUser> $admins */
686+
$admins = getUnknownValue();
687+
$admins[0]->grantPermission('x'); // element resolved to AdminUser via array<int, AdminUser>
688+
$key = 0;
689+
$admins[$key]->grantPermission('y'); // variable key works too
690+
691+
$admin = $admins[0];
692+
$admin->grantPermission('z'); // assigned from array access → AdminUser
693+
694+
$user = $users[0];
695+
$user->getEmail(); // assigned from array access → User

src/completion/resolver.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
/// - [`super::conditional_resolution`]: PHPStan conditional return type
1919
/// resolution at call sites.
2020
use crate::Backend;
21+
use crate::docblock;
2122
use crate::types::*;
2223

2324
use super::conditional_resolution::{
@@ -191,6 +192,26 @@ impl Backend {
191192
return vec![];
192193
}
193194

195+
// ── Array-element access: `$var[]` ──
196+
// When the subject ends with `[]`, the user wrote `$var[0]->` or
197+
// `$var[$key]->`. Resolve the base variable's generic/iterable
198+
// type and extract the element type.
199+
if let Some(base_var) = subject.strip_suffix("[]")
200+
&& base_var.starts_with('$')
201+
{
202+
let resolved = Self::resolve_array_element_type(
203+
base_var,
204+
content,
205+
cursor_offset,
206+
current_class,
207+
all_classes,
208+
class_loader,
209+
);
210+
if !resolved.is_empty() {
211+
return resolved;
212+
}
213+
}
214+
194215
// ── Variable like `$var` — resolve via assignments / parameter hints ──
195216
if subject.starts_with('$') {
196217
// When the cursor is inside a class, use the enclosing class
@@ -243,6 +264,43 @@ impl Backend {
243264
///
244265
/// Returns all candidate classes when the return type is a union
245266
/// (e.g. `A|B`).
267+
/// Resolve the element type of an array/list variable accessed with `[]`.
268+
///
269+
/// Given a base variable name like `$admins`, searches backward from
270+
/// `cursor_offset` for a `@var` / `@param` docblock annotation that
271+
/// declares a generic iterable type (e.g. `array<int, AdminUser>`,
272+
/// `list<User>`, `User[]`). Extracts the element type and resolves
273+
/// it to `ClassInfo`.
274+
fn resolve_array_element_type(
275+
base_var: &str,
276+
content: &str,
277+
cursor_offset: u32,
278+
current_class: Option<&ClassInfo>,
279+
all_classes: &[ClassInfo],
280+
class_loader: &dyn Fn(&str) -> Option<ClassInfo>,
281+
) -> Vec<ClassInfo> {
282+
let current_class_name = current_class.map(|c| c.name.as_str()).unwrap_or("");
283+
284+
// Search backward from the cursor for a @var/@param annotation on
285+
// this variable that includes a generic type.
286+
let raw_type = match docblock::find_iterable_raw_type_in_source(
287+
content,
288+
cursor_offset as usize,
289+
base_var,
290+
) {
291+
Some(t) => t,
292+
None => return vec![],
293+
};
294+
295+
// Extract the generic element type (e.g. `list<User>` → `User`).
296+
let element_type = match docblock::types::extract_generic_value_type(&raw_type) {
297+
Some(t) => t,
298+
None => return vec![],
299+
};
300+
301+
Self::type_hint_to_classes(&element_type, current_class_name, all_classes, class_loader)
302+
}
303+
246304
pub(super) fn resolve_call_return_types(
247305
call_body: &str,
248306
text_args: &str,

src/completion/variable_resolution.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -944,6 +944,34 @@ impl Backend {
944944
}
945945
return;
946946
}
947+
// Check RHS is an array access: `$var = $arr[0]` or `$var = $arr[$key]`
948+
// Resolve the base array's generic/iterable type and extract
949+
// the element type.
950+
if let Expression::ArrayAccess(array_access) = assignment.rhs {
951+
// The base expression must be a simple variable (e.g. `$admins`).
952+
if let Expression::Variable(Variable::Direct(base_dv)) = array_access.array {
953+
let base_var = base_dv.name.to_string();
954+
// Search backward from this assignment for a @var/@param
955+
// annotation on the base variable with a generic type.
956+
let assign_offset = assignment.span().start.offset as usize;
957+
if let Some(raw_type) = docblock::find_iterable_raw_type_in_source(
958+
content,
959+
assign_offset,
960+
&base_var,
961+
) && let Some(element_type) =
962+
docblock::types::extract_generic_value_type(&raw_type)
963+
{
964+
let resolved = Self::type_hint_to_classes(
965+
&element_type,
966+
current_class_name,
967+
all_classes,
968+
class_loader,
969+
);
970+
push_results(results, resolved, conditional);
971+
}
972+
}
973+
return;
974+
}
947975
// Check RHS is a function call: `$var = someFunction(…)`
948976
// Look up the function's return type and resolve to a class.
949977
if let Expression::Call(call) = assignment.rhs {

src/subject_extraction.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,32 @@ pub(crate) fn skip_balanced_parens_back(chars: &[char], pos: usize) -> Option<us
5959
None
6060
}
6161

62+
/// Skip backwards past a balanced bracket group `[…]` in a char slice.
63+
///
64+
/// `pos` must point one past the closing `]`. Returns the index of the
65+
/// opening `[`, or `None` if brackets are unbalanced.
66+
pub(crate) fn skip_balanced_brackets_back(chars: &[char], pos: usize) -> Option<usize> {
67+
if pos == 0 || chars[pos - 1] != ']' {
68+
return None;
69+
}
70+
let mut depth: u32 = 0;
71+
let mut j = pos;
72+
while j > 0 {
73+
j -= 1;
74+
match chars[j] {
75+
']' => depth += 1,
76+
'[' => {
77+
depth -= 1;
78+
if depth == 0 {
79+
return Some(j);
80+
}
81+
}
82+
_ => {}
83+
}
84+
}
85+
None
86+
}
87+
6288
/// Check if the `new` keyword (followed by whitespace) appears immediately
6389
/// before the identifier starting at position `ident_start`.
6490
///
@@ -185,6 +211,22 @@ pub(crate) fn extract_arrow_subject(chars: &[char], arrow_pos: usize) -> String
185211
// (past any `?` and whitespace).
186212
end = i;
187213

214+
// ── Array access: detect `]` ──
215+
// e.g. `$admins[0]->`, `$admins[$key]->`
216+
// Skip backwards past balanced `[…]` and extract the variable before it.
217+
if i > 0
218+
&& chars[i - 1] == ']'
219+
&& let Some(bracket_open) = skip_balanced_brackets_back(chars, i)
220+
{
221+
// Now extract whatever is before the `[` — typically a variable.
222+
let before_bracket = extract_simple_variable(chars, bracket_open);
223+
if !before_bracket.is_empty() {
224+
// Return subject with `[]` suffix so the resolver knows
225+
// this is an array-element access.
226+
return format!("{}[]", before_bracket);
227+
}
228+
}
229+
188230
// ── Function / method call or `new` expression: detect `)` ──
189231
// e.g. `app()->`, `$this->getService()->`, `Class::make()->`,
190232
// `new Foo()->`, `(new Foo())->`

tests/completion_variable_names.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1912,18 +1912,18 @@ async fn test_completion_variables_at_eof_with_actual_example_php() {
19121912
let backend = create_test_backend();
19131913
let uri = Url::parse("file:///example_eof.php").unwrap();
19141914

1915-
let base_content = std::fs::read_to_string("example.php")
1916-
.expect("example.php must exist in the project root");
1917-
1915+
let base_content =
1916+
std::fs::read_to_string("example.php").expect("example.php must exist in the project root");
1917+
19181918
// Scenario: user appends "$" on a new line at the end.
19191919
// base_content already ends with "\n", so we just append "$\n".
19201920
let text = format!("{}$\n", base_content);
19211921
let line_count = text.lines().count() as u32;
1922-
1922+
19231923
// The `$` is on the last non-empty line (line_count - 2, since
19241924
// trailing \n produces an empty final line that .lines() drops,
19251925
// but the `$` line is the last element returned by .lines()).
1926-
let dollar_line = line_count - 1; // 0-indexed, last line from .lines()
1926+
let dollar_line = line_count - 1; // 0-indexed, last line from .lines()
19271927

19281928
let items = complete_at(&backend, &uri, &text, dollar_line, 1).await;
19291929

0 commit comments

Comments
 (0)