@@ -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`] .
11381145pub ( 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+
12121316fn 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 " ;
0 commit comments