Skip to content

Commit 3b15e5c

Browse files
committed
Support nested array shape assignments in variable resolution
1 parent 2783641 commit 3b15e5c

5 files changed

Lines changed: 314 additions & 77 deletions

File tree

docs/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **Nested array shape inference from multi-level key assignments.** Assignments like `$b['a']['b'] = 'x'` now produce a nested array shape type (`array{a: array{b: string}}`), enabling array key completion and hover for arrays built incrementally with nested keys. Previously only single-level key assignments were tracked.
13+
14+
### Fixed
15+
16+
- **False-positive undefined variable on nested array access assignment.** `$b['a']['a'] = 'a'` no longer flags `$b` as undefined. The scope collector now correctly propagates write context through nested `ArrayAccess` and `ArrayAppend` expressions, so the base variable is recognized as defined.
17+
1018
## [0.7.0] - 2026-04-08
1119

1220
### Added

src/completion/variable/resolution.rs

Lines changed: 137 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -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"`.

src/completion/variable/resolution_tests.rs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -823,3 +823,102 @@ class LoggedConnection extends BaseConnector {
823823
names
824824
);
825825
}
826+
827+
/// Nested array access assignments like `$b['a']['b'] = 'x'` should
828+
/// produce a nested array shape `array{a: array{b: string}}`.
829+
#[test]
830+
fn resolve_var_shape_from_nested_key_assignments() {
831+
let content = r#"<?php
832+
function test() {
833+
$b['a']['a'] = 'a';
834+
$b['x']
835+
}
836+
"#;
837+
let cursor_offset = content.find("$b['x']").unwrap() as u32;
838+
839+
let results = super::resolve_variable_types(
840+
"$b",
841+
&ClassInfo::default(),
842+
&[],
843+
content,
844+
cursor_offset,
845+
&|_| None,
846+
Loaders::default(),
847+
);
848+
849+
assert!(!results.is_empty(), "Should resolve $b to a type");
850+
let ts = ResolvedType::types_joined(&results).to_string();
851+
assert!(
852+
ts.contains("a: array{a: string}"),
853+
"Shape should contain nested 'a: array{{a: string}}', got: {ts}"
854+
);
855+
}
856+
857+
/// Deeply nested key assignments like `$c['a']['b']['c'] = 42` should
858+
/// produce `array{a: array{b: array{c: int}}}`.
859+
#[test]
860+
fn resolve_var_shape_from_deeply_nested_key_assignments() {
861+
let content = r#"<?php
862+
function test() {
863+
$config['db']['host']['primary'] = 'localhost';
864+
$config['x']
865+
}
866+
"#;
867+
let cursor_offset = content.find("$config['x']").unwrap() as u32;
868+
869+
let results = super::resolve_variable_types(
870+
"$config",
871+
&ClassInfo::default(),
872+
&[],
873+
content,
874+
cursor_offset,
875+
&|_| None,
876+
Loaders::default(),
877+
);
878+
879+
assert!(!results.is_empty(), "Should resolve $config to a type");
880+
let ts = ResolvedType::types_joined(&results).to_string();
881+
assert!(
882+
ts.contains("db: array{host: array{primary: string}}"),
883+
"Shape should contain deeply nested keys, got: {ts}"
884+
);
885+
}
886+
887+
/// Mixed single-level and nested key assignments should merge correctly.
888+
#[test]
889+
fn resolve_var_shape_mixed_single_and_nested_keys() {
890+
let content = r#"<?php
891+
function test() {
892+
$data['name'] = 'John';
893+
$data['address']['city'] = 'NYC';
894+
$data['address']['zip'] = '10001';
895+
$data['x']
896+
}
897+
"#;
898+
let cursor_offset = content.find("$data['x']").unwrap() as u32;
899+
900+
let results = super::resolve_variable_types(
901+
"$data",
902+
&ClassInfo::default(),
903+
&[],
904+
content,
905+
cursor_offset,
906+
&|_| None,
907+
Loaders::default(),
908+
);
909+
910+
assert!(!results.is_empty(), "Should resolve $data to a type");
911+
let ts = ResolvedType::types_joined(&results).to_string();
912+
assert!(
913+
ts.contains("name: string"),
914+
"Shape should contain 'name: string', got: {ts}"
915+
);
916+
assert!(
917+
ts.contains("city: string"),
918+
"Shape should contain nested 'city: string', got: {ts}"
919+
);
920+
assert!(
921+
ts.contains("zip: string"),
922+
"Shape should contain nested 'zip: string', got: {ts}"
923+
);
924+
}

0 commit comments

Comments
 (0)