@@ -3072,88 +3072,92 @@ pub(in crate::completion) fn check_expression_for_assignment<'b>(
30723072 }
30733073
30743074 // ── Incremental key assignment: `$var['key'] = expr;` ──
3075+ // Also handles nested assignments: `$var['a']['b'] = expr;`
30753076 // Track string-keyed assignments and merge them into the
30763077 // base type's array shape. This produces type strings like
30773078 // `array{name: string, age: int}` which downstream consumers
30783079 // use for shape-aware completion and hover.
3079- if let Expression :: ArrayAccess ( array_access) = assignment. lhs
3080- && let Expression :: Variable ( Variable :: Direct ( dv) ) = array_access. array
3081- && dv. name == var_name
3082- {
3083- // ── Skip when cursor is inside the RHS ────────
3084- // Same guard as the base-assignment path below.
3085- // Without this, `$var['key'] = $var['key']->method()`
3086- // would infinitely recurse: resolving the RHS triggers
3087- // resolution of `$var`, which re-discovers the same
3088- // assignment and resolves its RHS again.
3089- let rhs_start = assignment. rhs . span ( ) . start . offset ;
3090- let assign_end = assignment. span ( ) . end . offset ;
3091- if ctx. cursor_offset >= rhs_start && ctx. cursor_offset <= assign_end {
3092- return ;
3093- }
3094-
3095- let key = extract_array_key_for_shape ( array_access. index ) ;
3096- if let Some ( key) = key {
3097- // ── String-literal key: merge into array shape ──
3098- let rhs_ctx = ctx. with_cursor_offset ( assignment. span ( ) . start . offset ) ;
3099- let resolved =
3100- super :: rhs_resolution:: resolve_rhs_expression ( assignment. rhs , & rhs_ctx) ;
3101- let value_php_type = if !resolved. is_empty ( ) {
3102- ResolvedType :: types_joined ( & resolved)
3103- } else {
3104- PhpType :: mixed ( )
3105- } ;
3106- // Read the current base type from results (if any)
3107- // and merge the new key into its shape.
3108- let base = results
3109- . last ( )
3110- . map ( |rt| & rt. type_string )
3111- . cloned ( )
3112- . unwrap_or_else ( PhpType :: array) ;
3113- let merged = merge_shape_key ( & base, & key, & value_php_type) ;
3114- // Replace results with the enriched shape type.
3115- // Use extend_unique so this works in conditional
3116- // branches (appends) as well as unconditional
3117- // (results cleared first by push_results via the
3118- // base assignment that preceded this).
3119- // We always push here without clearing — shape keys
3120- // accumulate on top of the existing base.
3121- let new_rt = ResolvedType :: from_type_string ( merged) ;
3122- results. clear ( ) ;
3123- results. push ( new_rt) ;
3124- } else {
3125- // ── Non-literal key (variable, numeric, expression) ──
3126- // Track the RHS type as the array's value type so that
3127- // subsequent `foreach` iteration and bracket access
3128- // resolve element members. This handles patterns like
3129- // `$arr[$id] = $orderLine` inside a loop.
3130- let rhs_ctx = ctx. with_cursor_offset ( assignment. span ( ) . start . offset ) ;
3131- let resolved =
3132- super :: rhs_resolution:: resolve_rhs_expression ( assignment. rhs , & rhs_ctx) ;
3133- let value_php_type = if !resolved. is_empty ( ) {
3134- ResolvedType :: types_joined ( & resolved)
3135- } else {
3136- PhpType :: mixed ( )
3137- } ;
3138- let base_type = results
3139- . last ( )
3140- . map ( |rt| & rt. type_string )
3141- . cloned ( )
3142- . unwrap_or_else ( PhpType :: array) ;
3143- // When the base already has a shape type from prior
3144- // string-keyed assignments, do not overwrite it with
3145- // a generic element type — shapes take precedence.
3146- if base_type. is_array_shape ( ) {
3080+ if let Expression :: ArrayAccess ( array_access) = assignment. lhs {
3081+ // Extract the base variable name and chain of keys from
3082+ // potentially nested array accesses like `$var['a']['b']['c']`.
3083+ if let Some ( ( base_name, key_chain) ) =
3084+ extract_nested_array_access_chain ( array_access)
3085+ && base_name == var_name
3086+ {
3087+ // ── Skip when cursor is inside the RHS ────────
3088+ // Same guard as the base-assignment path below.
3089+ // Without this, `$var['key'] = $var['key']->method()`
3090+ // would infinitely recurse: resolving the RHS triggers
3091+ // resolution of `$var`, which re-discovers the same
3092+ // assignment and resolves its RHS again.
3093+ let rhs_start = assignment. rhs . span ( ) . start . offset ;
3094+ let assign_end = assignment. span ( ) . end . offset ;
3095+ if ctx. cursor_offset >= rhs_start && ctx. cursor_offset <= assign_end {
31473096 return ;
31483097 }
3149- // Infer the key type from the index expression.
3150- let key_php_type = infer_array_key_type ( array_access. index , & rhs_ctx) ;
3151- let merged = merge_keyed_type ( & base_type, & key_php_type, & value_php_type) ;
3152- let new_rt = ResolvedType :: from_type_string ( merged) ;
3153- results. clear ( ) ;
3154- results. push ( new_rt) ;
3098+
3099+ // Check if all keys in the chain are string literals.
3100+ let all_string_keys: Option < Vec < String > > = key_chain
3101+ . iter ( )
3102+ . map ( |idx| extract_array_key_for_shape ( idx) )
3103+ . collect ( ) ;
3104+
3105+ if let Some ( keys) = all_string_keys {
3106+ // ── All string-literal keys: merge into (nested) array shape ──
3107+ let rhs_ctx = ctx. with_cursor_offset ( assignment. span ( ) . start . offset ) ;
3108+ let resolved =
3109+ super :: rhs_resolution:: resolve_rhs_expression ( assignment. rhs , & rhs_ctx) ;
3110+ let value_php_type = if !resolved. is_empty ( ) {
3111+ ResolvedType :: types_joined ( & resolved)
3112+ } else {
3113+ PhpType :: mixed ( )
3114+ } ;
3115+ // Read the current base type from results (if any)
3116+ // and merge the new key(s) into its shape.
3117+ let base = results
3118+ . last ( )
3119+ . map ( |rt| & rt. type_string )
3120+ . cloned ( )
3121+ . unwrap_or_else ( PhpType :: array) ;
3122+ let merged = merge_nested_shape_keys ( & base, & keys, & value_php_type) ;
3123+ // Replace results with the enriched shape type.
3124+ let new_rt = ResolvedType :: from_type_string ( merged) ;
3125+ results. clear ( ) ;
3126+ results. push ( new_rt) ;
3127+ } else if key_chain. len ( ) == 1 {
3128+ // ── Single non-literal key (variable, numeric, expression) ──
3129+ // Track the RHS type as the array's value type so that
3130+ // subsequent `foreach` iteration and bracket access
3131+ // resolve element members. This handles patterns like
3132+ // `$arr[$id] = $orderLine` inside a loop.
3133+ let rhs_ctx = ctx. with_cursor_offset ( assignment. span ( ) . start . offset ) ;
3134+ let resolved =
3135+ super :: rhs_resolution:: resolve_rhs_expression ( assignment. rhs , & rhs_ctx) ;
3136+ let value_php_type = if !resolved. is_empty ( ) {
3137+ ResolvedType :: types_joined ( & resolved)
3138+ } else {
3139+ PhpType :: mixed ( )
3140+ } ;
3141+ let base_type = results
3142+ . last ( )
3143+ . map ( |rt| & rt. type_string )
3144+ . cloned ( )
3145+ . unwrap_or_else ( PhpType :: array) ;
3146+ // When the base already has a shape type from prior
3147+ // string-keyed assignments, do not overwrite it with
3148+ // a generic element type — shapes take precedence.
3149+ if base_type. is_array_shape ( ) {
3150+ return ;
3151+ }
3152+ // Infer the key type from the index expression.
3153+ let key_php_type = infer_array_key_type ( key_chain[ 0 ] , & rhs_ctx) ;
3154+ let merged = merge_keyed_type ( & base_type, & key_php_type, & value_php_type) ;
3155+ let new_rt = ResolvedType :: from_type_string ( merged) ;
3156+ results. clear ( ) ;
3157+ results. push ( new_rt) ;
3158+ }
3159+ return ;
31553160 }
3156- return ;
31573161 }
31583162
31593163 // ── Push assignment: `$var[] = expr;` ──
@@ -3240,6 +3244,64 @@ pub(in crate::completion) fn check_expression_for_assignment<'b>(
32403244
32413245// ── Shape mutation helpers ───────────────────────────────────────────
32423246
3247+ /// Walk a (possibly nested) `ArrayAccess` chain and return the base
3248+ /// variable name and the ordered list of index expressions from
3249+ /// outermost to innermost.
3250+ ///
3251+ /// For `$var['a']['b']['c']` returns `Some(("$var", [expr_a, expr_b, expr_c]))`.
3252+ /// Returns `None` when the base expression is not a simple direct variable.
3253+ fn extract_nested_array_access_chain < ' a , ' b > (
3254+ outermost : & ' a ArrayAccess < ' b > ,
3255+ ) -> Option < ( String , Vec < & ' a Expression < ' b > > ) > {
3256+ let mut keys: Vec < & ' a Expression < ' b > > = Vec :: new ( ) ;
3257+ keys. push ( outermost. index ) ;
3258+
3259+ let mut current: & ' a Expression < ' b > = outermost. array ;
3260+ loop {
3261+ match current {
3262+ Expression :: ArrayAccess ( inner) => {
3263+ keys. push ( inner. index ) ;
3264+ current = inner. array ;
3265+ }
3266+ Expression :: Variable ( Variable :: Direct ( dv) ) => {
3267+ // We collected keys innermost-first; reverse so the
3268+ // outermost key (closest to the variable) comes first.
3269+ keys. reverse ( ) ;
3270+ return Some ( ( dv. name . to_string ( ) , keys) ) ;
3271+ }
3272+ _ => return None ,
3273+ }
3274+ }
3275+ }
3276+
3277+ /// Merge a chain of string keys into a (possibly nested) array shape.
3278+ ///
3279+ /// For keys `["a", "b"]` and value type `string`, produces:
3280+ /// `array{a: array{b: string}}`
3281+ ///
3282+ /// When the base already contains entries, they are preserved and the
3283+ /// nested key path is merged in. For example, merging `["a", "c"]`
3284+ /// with value `int` into `array{a: array{b: string}}` produces:
3285+ /// `array{a: array{b: string, c: int}}`
3286+ fn merge_nested_shape_keys ( base : & PhpType , keys : & [ String ] , value_type : & PhpType ) -> PhpType {
3287+ debug_assert ! ( !keys. is_empty( ) ) ;
3288+ if keys. len ( ) == 1 {
3289+ return merge_shape_key ( base, & keys[ 0 ] , value_type) ;
3290+ }
3291+
3292+ // For nested keys, we need to:
3293+ // 1. Look up the existing inner type for the first key
3294+ // 2. Recursively merge the remaining keys into that inner type
3295+ // 3. Merge the result back at the first key level
3296+ let first_key = & keys[ 0 ] ;
3297+ let inner_base = base
3298+ . shape_value_type ( first_key)
3299+ . cloned ( )
3300+ . unwrap_or_else ( PhpType :: array) ;
3301+ let inner_merged = merge_nested_shape_keys ( & inner_base, & keys[ 1 ..] , value_type) ;
3302+ merge_shape_key ( base, first_key, & inner_merged)
3303+ }
3304+
32433305/// Extract a string key from an array access index expression.
32443306///
32453307/// Returns `Some(key)` for string-literal keys like `'name'` or `"age"`.
0 commit comments