Skip to content

Commit ea1942a

Browse files
committed
Add support for growing aray lists
1 parent 2193b73 commit ea1942a

4 files changed

Lines changed: 715 additions & 9 deletions

File tree

example.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1598,6 +1598,22 @@ public function methodReturnShapeKeys(): void {
15981598
$result['']; // Key completion suggests: status, code, user
15991599
// Details: status: string, code: int, user: User
16001600

1601+
// ─── Push-Style List Type Inference ────────────────────────────────────────
1602+
//
1603+
// PHPantomLSP infers list<Type> from push-style array construction:
1604+
// $arr = [];
1605+
// $arr[] = new User(); // Infers list<User>
1606+
// $arr[] = new AdminUser(); // Infers list<User|AdminUser>
1607+
//
1608+
// Element access resolves through the inferred generic type:
1609+
// $arr[0]-> offers members from User and AdminUser
1610+
1611+
$users = [];
1612+
$users[] = new User();
1613+
$users[] = new AdminUser();
1614+
$users[0]->getName(); // Resolved: User::getName() and AdminUser members
1615+
$users[0]->grantPermission(''); // Resolved: AdminUser::grantPermission()
1616+
16011617
// ─── Literal Array Value Type → Member Access ──────────────────────────────
16021618
//
16031619
// When the value type of an array shape key is a class, member access

src/completion/array_shape.rs

Lines changed: 195 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,7 @@ impl Backend {
536536
/// - `$var = ['key' => value, …];` → synthesised `array{key: type, …}`
537537
/// - Incremental `$var['key'] = expr;` assignments after the initial
538538
/// assignment are merged into the shape.
539+
/// - `$var[] = expr;` push-style assignments → synthesised `list<Type>`
539540
fn resolve_raw_type_from_assignment(
540541
&self,
541542
var_name: &str,
@@ -561,18 +562,19 @@ impl Backend {
561562
// Case 1: RHS is an array literal — `[…]` or `array(…)`.
562563
// Check this BEFORE the function-call case because `array(…)`
563564
// ends with `)` and would otherwise be mistaken for a call.
564-
// Also scan for incremental `$var['key'] = expr;` assignments.
565-
// Parse the keys from the literal and also scan for incremental
566-
// `$var['key'] = expr;` assignments between the initial
567-
// assignment and the cursor.
565+
// Also scan for incremental `$var['key'] = expr;` assignments
566+
// and push-style `$var[] = expr;` assignments.
568567
let base_entries = parse_array_literal_entries(rhs_text);
569568

570569
// Scan for incremental `$var['key'] = expr;` assignments.
571570
let after_assign = assign_pos + assign_pattern.len() + semi_pos + 1; // past the `;`
572571
let incremental =
573572
collect_incremental_key_assignments(var_name, content, after_assign, cursor_offset);
574573

575-
if base_entries.is_some() || !incremental.is_empty() {
574+
// Scan for push-style `$var[] = expr;` assignments.
575+
let push_types = collect_push_assignments(var_name, content, after_assign, cursor_offset);
576+
577+
if base_entries.is_some() || !incremental.is_empty() || !push_types.is_empty() {
576578
let mut entries: Vec<(String, String)> = base_entries.unwrap_or_default();
577579
// Merge incremental assignments — later assignments for the
578580
// same key override earlier ones.
@@ -583,13 +585,18 @@ impl Backend {
583585
entries.push((k, v));
584586
}
585587
}
588+
// If there are string-keyed entries, prefer the array shape.
586589
if !entries.is_empty() {
587590
let shape_parts: Vec<String> = entries
588591
.iter()
589592
.map(|(k, v)| format!("{}: {}", k, v))
590593
.collect();
591594
return Some(format!("array{{{}}}", shape_parts.join(", ")));
592595
}
596+
// No string-keyed entries — try push-style list inference.
597+
if let Some(list_type) = build_list_type_from_push_types(&push_types) {
598+
return Some(list_type);
599+
}
593600
}
594601

595602
// Case 2: RHS is a function call — `functionName(…)`
@@ -1134,7 +1141,7 @@ fn infer_literal_type(expr: &str) -> String {
11341141
///
11351142
/// Returns a list of `(key, inferred_type)` pairs. Only string-keyed
11361143
/// assignments are collected; `$var[] = expr;` push-style assignments
1137-
/// are skipped since they produce numeric keys.
1144+
/// are handled separately by [`collect_push_assignments`].
11381145
pub(super) fn collect_incremental_key_assignments(
11391146
var_name: &str,
11401147
content: &str,
@@ -1209,6 +1216,103 @@ pub(super) fn collect_incremental_key_assignments(
12091216
entries
12101217
}
12111218

1219+
/// Scan for push-style `$var[] = expr;` assignments in the content
1220+
/// between `start_offset` and `end_offset`.
1221+
///
1222+
/// Returns a list of inferred type strings (one per push assignment).
1223+
/// Duplicate types are preserved so callers can deduplicate as needed.
1224+
///
1225+
/// # Example
1226+
///
1227+
/// ```php
1228+
/// $arr = [];
1229+
/// $arr[] = new User(); // → "User"
1230+
/// $arr[] = new AdminUser(); // → "AdminUser"
1231+
/// ```
1232+
///
1233+
/// The caller can combine these into `list<User|AdminUser>`.
1234+
pub(super) fn collect_push_assignments(
1235+
var_name: &str,
1236+
content: &str,
1237+
start_offset: usize,
1238+
end_offset: usize,
1239+
) -> Vec<String> {
1240+
let search_area = match content.get(start_offset..end_offset) {
1241+
Some(s) => s,
1242+
None => return vec![],
1243+
};
1244+
1245+
let mut types = Vec::new();
1246+
// Pattern: `$var[] = `
1247+
let prefix = format!("{}[]", var_name);
1248+
1249+
let mut pos = 0;
1250+
while let Some(found) = search_area[pos..].find(&prefix) {
1251+
let abs = pos + found;
1252+
let after_brackets = abs + prefix.len();
1253+
1254+
let rest = match search_area.get(after_brackets..) {
1255+
Some(r) => r,
1256+
None => break,
1257+
};
1258+
1259+
// After `$var[]`, expect ` = ` (with optional whitespace).
1260+
let trimmed = rest.trim_start();
1261+
if !trimmed.starts_with('=') {
1262+
pos = after_brackets;
1263+
continue;
1264+
}
1265+
1266+
// Make sure it's `=` and not `==` or `===`.
1267+
let after_eq = &trimmed[1..];
1268+
if after_eq.starts_with('=') {
1269+
pos = after_brackets;
1270+
continue;
1271+
}
1272+
1273+
let rhs_and_rest = after_eq;
1274+
1275+
// Find `;` respecting nesting.
1276+
if let Some(semi) = find_balanced_semicolon(rhs_and_rest) {
1277+
let value_expr = rhs_and_rest[..semi].trim();
1278+
let inferred = infer_literal_type(value_expr);
1279+
types.push(inferred);
1280+
}
1281+
1282+
pos = after_brackets;
1283+
}
1284+
1285+
types
1286+
}
1287+
1288+
/// Build a `list<Type>` type string from a collection of push-assignment
1289+
/// value types.
1290+
///
1291+
/// Deduplicates types and joins them with `|` inside the generic parameter.
1292+
/// Returns `None` if the input is empty or all types are `mixed`.
1293+
pub(super) fn build_list_type_from_push_types(types: &[String]) -> Option<String> {
1294+
if types.is_empty() {
1295+
return None;
1296+
}
1297+
1298+
// Deduplicate while preserving first-seen order.
1299+
let mut seen = Vec::new();
1300+
for t in types {
1301+
if !seen.contains(t) {
1302+
seen.push(t.clone());
1303+
}
1304+
}
1305+
1306+
// If all types are `mixed`, don't synthesize a list type — it's not
1307+
// useful for completion.
1308+
if seen.iter().all(|t| t == "mixed") {
1309+
return None;
1310+
}
1311+
1312+
let inner = seen.join("|");
1313+
Some(format!("list<{}>", inner))
1314+
}
1315+
12121316
fn find_top_level_paren(s: &str) -> Option<usize> {
12131317
let mut depth_angle = 0i32;
12141318
let bytes = s.as_bytes();
@@ -1557,13 +1661,97 @@ mod tests {
15571661

15581662
#[test]
15591663
fn test_collect_incremental_push_ignored() {
1560-
// $var[] = expr should be ignored (no string key)
1664+
// $var[] = expr should be ignored by string-key collector
15611665
let content = "$v = [];\n$v[] = new User();\n$v['name'] = 'x';\n";
15621666
let entries = collect_incremental_key_assignments("$v", content, 8, content.len());
15631667
assert_eq!(entries.len(), 1);
15641668
assert_eq!(entries[0].0, "name");
15651669
}
15661670

1671+
#[test]
1672+
fn test_collect_push_basic() {
1673+
let content = "$v = [];\n$v[] = new User();\n$v[] = new AdminUser();\n";
1674+
let types = collect_push_assignments("$v", content, 8, content.len());
1675+
assert_eq!(types.len(), 2);
1676+
assert_eq!(types[0], "User");
1677+
assert_eq!(types[1], "AdminUser");
1678+
}
1679+
1680+
#[test]
1681+
fn test_collect_push_string_literals() {
1682+
let content = "$v = [];\n$v[] = 'hello';\n$v[] = 'world';\n";
1683+
let types = collect_push_assignments("$v", content, 8, content.len());
1684+
assert_eq!(types.len(), 2);
1685+
assert_eq!(types[0], "string");
1686+
assert_eq!(types[1], "string");
1687+
}
1688+
1689+
#[test]
1690+
fn test_collect_push_skips_keyed() {
1691+
// $var['key'] = expr should NOT be collected by push scanner
1692+
let content = "$v = [];\n$v['name'] = 'x';\n$v[] = new User();\n";
1693+
let types = collect_push_assignments("$v", content, 8, content.len());
1694+
assert_eq!(types.len(), 1);
1695+
assert_eq!(types[0], "User");
1696+
}
1697+
1698+
#[test]
1699+
fn test_collect_push_empty_range() {
1700+
let types = collect_push_assignments("$v", "", 0, 0);
1701+
assert!(types.is_empty());
1702+
}
1703+
1704+
#[test]
1705+
fn test_collect_push_no_double_equals() {
1706+
// $var[] == expr should NOT be collected (comparison, not assignment)
1707+
let content = "$v = [];\n$v[] == new User();\n";
1708+
let types = collect_push_assignments("$v", content, 8, content.len());
1709+
assert!(types.is_empty());
1710+
}
1711+
1712+
#[test]
1713+
fn test_build_list_type_single() {
1714+
let types = vec!["User".to_string()];
1715+
assert_eq!(
1716+
build_list_type_from_push_types(&types),
1717+
Some("list<User>".to_string())
1718+
);
1719+
}
1720+
1721+
#[test]
1722+
fn test_build_list_type_union() {
1723+
let types = vec!["User".to_string(), "AdminUser".to_string()];
1724+
assert_eq!(
1725+
build_list_type_from_push_types(&types),
1726+
Some("list<User|AdminUser>".to_string())
1727+
);
1728+
}
1729+
1730+
#[test]
1731+
fn test_build_list_type_deduplicates() {
1732+
let types = vec![
1733+
"User".to_string(),
1734+
"User".to_string(),
1735+
"AdminUser".to_string(),
1736+
];
1737+
assert_eq!(
1738+
build_list_type_from_push_types(&types),
1739+
Some("list<User|AdminUser>".to_string())
1740+
);
1741+
}
1742+
1743+
#[test]
1744+
fn test_build_list_type_empty() {
1745+
let types: Vec<String> = vec![];
1746+
assert_eq!(build_list_type_from_push_types(&types), None);
1747+
}
1748+
1749+
#[test]
1750+
fn test_build_list_type_all_mixed() {
1751+
let types = vec!["mixed".to_string(), "mixed".to_string()];
1752+
assert_eq!(build_list_type_from_push_types(&types), None);
1753+
}
1754+
15671755
#[test]
15681756
fn test_collect_incremental_new_objects() {
15691757
let content = "$d = [];\n$d['user'] = new User();\n$d['addr'] = new Address();\n";

src/completion/resolver.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -444,7 +444,8 @@ impl Backend {
444444
// ── Array literal — `[…]` or `array(…)` ────────────────────
445445
// Check this BEFORE the function-call case because `array(…)`
446446
// ends with `)` and would otherwise be mistaken for a call.
447-
// Also scan for incremental `$var['key'] = expr;` assignments.
447+
// Also scan for incremental `$var['key'] = expr;` assignments
448+
// and push-style `$var[] = expr;` assignments.
448449
let base_entries = super::array_shape::parse_array_literal_entries(rhs_text);
449450

450451
let after_assign = rhs_start + semi_pos + 1; // past the `;`
@@ -455,7 +456,15 @@ impl Backend {
455456
cursor_offset,
456457
);
457458

458-
if base_entries.is_some() || !incremental.is_empty() {
459+
// Scan for push-style `$var[] = expr;` assignments.
460+
let push_types = super::array_shape::collect_push_assignments(
461+
base_var,
462+
content,
463+
after_assign,
464+
cursor_offset,
465+
);
466+
467+
if base_entries.is_some() || !incremental.is_empty() || !push_types.is_empty() {
459468
let mut entries: Vec<(String, String)> = base_entries.unwrap_or_default();
460469
// Merge incremental assignments — later assignments for the
461470
// same key override earlier ones.
@@ -466,13 +475,20 @@ impl Backend {
466475
entries.push((k, v));
467476
}
468477
}
478+
// If there are string-keyed entries, prefer the array shape.
469479
if !entries.is_empty() {
470480
let shape_parts: Vec<String> = entries
471481
.iter()
472482
.map(|(k, v)| format!("{}: {}", k, v))
473483
.collect();
474484
return Some(format!("array{{{}}}", shape_parts.join(", ")));
475485
}
486+
// No string-keyed entries — try push-style list inference.
487+
if let Some(list_type) =
488+
super::array_shape::build_list_type_from_push_types(&push_types)
489+
{
490+
return Some(list_type);
491+
}
476492
}
477493

478494
// RHS is a call expression — extract the return type.

0 commit comments

Comments
 (0)