Skip to content

Commit a0cc936

Browse files
committed
Track array value types from variable-key assignments
1 parent c541bac commit a0cc936

7 files changed

Lines changed: 691 additions & 33 deletions

File tree

docs/CHANGELOG.md

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

1010
### Added
1111

12+
- **Array value type tracking from variable-key assignments.** When an array is built incrementally with variable keys inside a loop (`$arr[$key] = $value`), the element type is now tracked. Subsequent `foreach` iteration, bracket access, and null-coalescing (`$arr[$key] ?? null`) all resolve the correct element type instead of falling back to bare `array`.
1213
- **`Conditionable::when()` / `unless()` chain resolution.** Chained calls after `when()` or `unless()` on Eloquent Builder, Collection, and any class using the `Conditionable` trait now resolve correctly. Previously the unresolved `TWhenReturnType` template parameter in the return type broke chain continuation, so `Builder::where(...)->when(...)->get()` lost type information after `when()`.
1314
- **`whereHas` / `whereDoesntHave` closure parameter from relation traversal.** The closure parameter in `whereHas`, `orWhereHas`, `whereDoesntHave`, `orWhereDoesntHave`, `withWhereHas`, and related methods is now typed as `Builder<RelatedModel>` instead of `Builder<CallingModel>`. The relation name string (first argument) is resolved by walking the model's relationship methods. Dot-notation chains like `whereHas('category.articles', fn($q) => ...)` follow each segment to the final related model. Works for static calls (`Brand::whereHas(...)`), builder instance calls (`$query->whereHas(...)`), and body-inferred relationships (no `@return` annotation). Falls back to the calling model's builder when the relation cannot be resolved.
1415
- **Eloquent `where{PropertyName}()` dynamic methods.** Calls like `User::whereBrandId(42)` and `$query->whereLangCode('en')` now resolve instead of producing `unknown_member` diagnostics. For each known column on an Eloquent model (from `$casts`, `$attributes`, `$fillable`/`$guarded`/`$hidden`/`$appends`, `$dates`, timestamps, and `@property` annotations), a virtual `where{StudlyCase}()` method is synthesized on both the model (as a static method) and the Builder (as an instance method). Each method accepts a `mixed` value parameter and returns `Builder<ConcreteModel>`, so chains like `Bakery::whereFlour('rye')->whereApricot(true)->get()` resolve end-to-end.

docs/todo.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ within the same impact tier.
2323

2424
| # | Item | Impact | Effort |
2525
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | ----------- |
26-
| T22 | [Array value type tracking from loop assignments](todo/type-inference.md#t22-array-value-type-tracking-from-loop-assignments) | Medium | Medium |
2726
| T23 | [`class-string<T>` static method dispatch](todo/type-inference.md#t23-class-stringt-static-method-dispatch) | Medium | Medium |
2827
| T21 | [Bidirectional template inference](todo/type-inference.md#t21-bidirectional-template-inference-upperlower-bounds) (closure return type → generic) | Medium | Medium-High |
2928
| H10 | [`return.unusedType` — remove unused type from return union](todo/phpstan-actions.md#h10-returnunusedtype--remove-unused-type-from-return-union) | Medium | Medium |

docs/todo/type-inference.md

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -488,37 +488,6 @@ simpler, but basic reconciliation can work with strings too).
488488

489489
---
490490

491-
## T22. Array value type tracking from loop assignments
492-
**Impact: Medium · Effort: Medium**
493-
494-
When a variable is initialized as `$arr = []` and then populated
495-
inside a loop with `$arr[$key] = $value`, PHPantom loses the element
496-
type entirely — it resolves `$arr` as bare `array`. Iterating the
497-
array in a subsequent `foreach` then produces `unresolved_member_access`
498-
on every element access.
499-
500-
Examples from triage:
501-
502-
- `$bundleProductCounts[$id] = ['bundle' => $productBundle, 'count' => 1]`
503-
in a loop, then `$bundleProductCount['bundle']->parentProduct()` in
504-
a second loop. (`ProductSupplyAmountChangeListener:56,58`.)
505-
- `$warehouseOrderLines[$key] = $orderLine` (where `$orderLine` is
506-
`PCNPurchaseOrderLine`) in a loop, then
507-
`$warehouseOrderLines[$key] ?? null` resolves as just `null` instead
508-
of `PCNPurchaseOrderLine|null`. (`PCNService:1073,1077`.)
509-
510-
The second example also requires the null-coalescing operator (`??`)
511-
to produce a union of both sides rather than only the fallback.
512-
513-
**Implementation:** when processing an assignment like
514-
`$arr[$expr] = $rhs`, record the RHS type as the array's value type.
515-
When the same variable is accessed in a `foreach` or via bracket
516-
access, use the recorded value type for the element. For `$a ?? $b`,
517-
the result type should be `type($a) | type($b)` with `null` removed
518-
from the left side.
519-
520-
---
521-
522491
## T23. `class-string<T>` static method dispatch
523492
**Impact: Medium · Effort: Medium**
524493

example.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3180,6 +3180,38 @@ public function demo(): void
31803180
}
31813181

31823182

3183+
3184+
// ── Loop Array Build (variable-key assignment tracking) ─────────────────────
3185+
3186+
class LoopArrayBuildDemo
3187+
{
3188+
/** @param list<Pen> $pens */
3189+
public function demo(array $pens): void
3190+
{
3191+
// Variable-key assignment inside a loop: `$arr[$var] = $value`
3192+
// PHPantom tracks the RHS type as the array's element type.
3193+
$indexed = [];
3194+
foreach ($pens as $i => $pen) {
3195+
$key = $pen->color();
3196+
$indexed[$key] = $pen;
3197+
}
3198+
3199+
// Foreach over the built array resolves element members
3200+
foreach ($indexed as $item) {
3201+
$item->write(); // Pen method via element type tracking
3202+
}
3203+
3204+
// Bracket access resolves element type
3205+
$indexed['red']->color(); // Pen method
3206+
3207+
// Null-coalescing with guard clause
3208+
$found = $indexed['blue'] ?? null;
3209+
if ($found === null) { return; }
3210+
$found->write(); // narrowed to Pen
3211+
}
3212+
}
3213+
3214+
31833215
// ═══════════════════════════════════════════════════════════════════════════
31843216
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
31853217
// ┃ SCAFFOLDING — Supporting definitions below this line. ┃
@@ -5791,6 +5823,20 @@ function runDemoAssertions(): void
57915823
assert($tgMixed instanceof Pen, 'Else branch of is_array() must be Pen');
57925824
}
57935825

5826+
// ── Loop array build (variable-key assignment) ──────────────────────
5827+
$labPens = [new Pen('red'), new Pen('blue')];
5828+
$labIndexed = [];
5829+
foreach ($labPens as $labPen) {
5830+
$labKey = $labPen->color();
5831+
$labIndexed[$labKey] = $labPen;
5832+
}
5833+
assert($labIndexed['red'] instanceof Pen, 'Variable-key array element must be Pen');
5834+
foreach ($labIndexed as $labItem) {
5835+
assert($labItem instanceof Pen, 'Foreach over variable-key array must yield Pen');
5836+
}
5837+
$labFound = $labIndexed['blue'] ?? null;
5838+
assert($labFound instanceof Pen, 'Null-coalesce on variable-key array must resolve to Pen');
5839+
57945840
echo "All assertions passed.\n";
57955841
}
57965842

src/completion/variable/resolution.rs

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3074,8 +3074,8 @@ pub(in crate::completion) fn check_expression_for_assignment<'b>(
30743074
}
30753075

30763076
let key = extract_array_key_for_shape(array_access.index);
3077-
// Skip numeric-only keys and unresolvable indices.
30783077
if let Some(key) = key {
3078+
// ── String-literal key: merge into array shape ──
30793079
let rhs_ctx = ctx.with_cursor_offset(assignment.span().start.offset);
30803080
let resolved =
30813081
super::rhs_resolution::resolve_rhs_expression(assignment.rhs, &rhs_ctx);
@@ -3099,6 +3099,34 @@ pub(in crate::completion) fn check_expression_for_assignment<'b>(
30993099
let new_rt = ResolvedType::from_type_string(PhpType::parse(&merged));
31003100
results.clear();
31013101
results.push(new_rt);
3102+
} else {
3103+
// ── Non-literal key (variable, numeric, expression) ──
3104+
// Track the RHS type as the array's value type so that
3105+
// subsequent `foreach` iteration and bracket access
3106+
// resolve element members. This handles patterns like
3107+
// `$arr[$id] = $orderLine` inside a loop.
3108+
let rhs_ctx = ctx.with_cursor_offset(assignment.span().start.offset);
3109+
let resolved =
3110+
super::rhs_resolution::resolve_rhs_expression(assignment.rhs, &rhs_ctx);
3111+
let value_type = if !resolved.is_empty() {
3112+
ResolvedType::type_strings_joined(&resolved)
3113+
} else {
3114+
"mixed".to_string()
3115+
};
3116+
let base_string = results.last().map(|rt| rt.type_string.to_string());
3117+
let base = base_string.as_deref().unwrap_or("array");
3118+
// When the base already has a shape type from prior
3119+
// string-keyed assignments, do not overwrite it with
3120+
// a generic element type — shapes take precedence.
3121+
if base.starts_with("array{") {
3122+
return;
3123+
}
3124+
// Infer the key type from the index expression.
3125+
let key_type = infer_array_key_type(array_access.index, &rhs_ctx);
3126+
let merged = merge_keyed_type(base, &key_type, &value_type);
3127+
let new_rt = ResolvedType::from_type_string(PhpType::parse(&merged));
3128+
results.clear();
3129+
results.push(new_rt);
31023130
}
31033131
return;
31043132
}
@@ -3287,6 +3315,141 @@ fn merge_push_type(base: &str, value_type: &str) -> String {
32873315
format!("list<{}>", elem_types.join("|"))
32883316
}
32893317

3318+
/// Merge a keyed element type into an existing type string to produce
3319+
/// an `array<KeyType, ValueType>` type.
3320+
///
3321+
/// Similar to [`merge_push_type`] but preserves the key type from the
3322+
/// index expression instead of assuming sequential integer keys.
3323+
///
3324+
/// When the base already has a generic value type (e.g.
3325+
/// `array<string, User>`), the new value type is unioned with it and
3326+
/// key types are unioned as well.
3327+
///
3328+
/// Examples:
3329+
/// - `merge_keyed_type("array", "string", "User")` → `"array<string, User>"`
3330+
/// - `merge_keyed_type("array<string, User>", "string", "Admin")`
3331+
/// → `"array<string, User|Admin>"`
3332+
/// - `merge_keyed_type("array<string, User>", "int", "Admin")`
3333+
/// → `"array<string|int, User|Admin>"`
3334+
fn merge_keyed_type(base: &str, key_type: &str, value_type: &str) -> String {
3335+
use crate::php_type::PhpType;
3336+
3337+
let parsed_base = PhpType::parse(base);
3338+
3339+
// Collect existing key types from the base.
3340+
let mut key_types: Vec<String> = Vec::new();
3341+
if let Some(existing_key) = parsed_base.extract_key_type(false) {
3342+
let s = existing_key.to_string();
3343+
if !s.is_empty() {
3344+
key_types.push(s);
3345+
}
3346+
}
3347+
// Add the new key type.
3348+
let new_key_parsed = PhpType::parse(key_type);
3349+
for member in new_key_parsed.union_members() {
3350+
let s = member.to_string();
3351+
if !s.is_empty() && !key_types.contains(&s) {
3352+
key_types.push(s);
3353+
}
3354+
}
3355+
3356+
// Collect existing value types from the base.
3357+
let mut elem_types: Vec<String> = Vec::new();
3358+
if let Some(existing_elem) = parsed_base.extract_element_type() {
3359+
for member in existing_elem.union_members() {
3360+
let s = member.to_string();
3361+
if !s.is_empty() {
3362+
elem_types.push(s);
3363+
}
3364+
}
3365+
}
3366+
// Add the new value type.
3367+
let new_val_parsed = PhpType::parse(value_type);
3368+
for member in new_val_parsed.union_members() {
3369+
let s = member.to_string();
3370+
if !s.is_empty() && !elem_types.contains(&s) {
3371+
elem_types.push(s);
3372+
}
3373+
}
3374+
3375+
if elem_types.is_empty() {
3376+
return "array".to_string();
3377+
}
3378+
3379+
let val_str = elem_types.join("|");
3380+
if key_types.is_empty() {
3381+
// No key type information — use a single-param generic.
3382+
format!("array<{}>", val_str)
3383+
} else {
3384+
format!("array<{}, {}>", key_types.join("|"), val_str)
3385+
}
3386+
}
3387+
3388+
/// Infer the key type of an array-access index expression.
3389+
///
3390+
/// Returns `"string"` for expressions that are known to produce
3391+
/// strings (string literals, method calls returning `string`, string
3392+
/// variables), `"int"` for integer expressions, and `"int|string"`
3393+
/// when the type cannot be determined.
3394+
fn infer_array_key_type(index: &Expression<'_>, ctx: &VarResolutionCtx<'_>) -> String {
3395+
// Fast path: literal values.
3396+
match index {
3397+
Expression::Literal(Literal::Integer(_)) => return "int".to_string(),
3398+
Expression::Literal(Literal::String(_)) => return "string".to_string(),
3399+
_ => {}
3400+
}
3401+
3402+
// Resolve the expression type through the standard pipeline.
3403+
let resolved = super::rhs_resolution::resolve_rhs_expression(index, ctx);
3404+
if !resolved.is_empty() {
3405+
let joined = ResolvedType::type_strings_joined(&resolved);
3406+
let key_str = joined.to_string();
3407+
// Normalise the resolved type to a valid array key type.
3408+
// PHP array keys are always int or string; bool and null are
3409+
// coerced to int, float is truncated to int.
3410+
if is_int_like_key(&key_str) {
3411+
return "int".to_string();
3412+
}
3413+
if key_str == "string" || key_str == "non-empty-string" || key_str == "class-string" {
3414+
return "string".to_string();
3415+
}
3416+
if key_str == "int|string" || key_str == "string|int" || key_str == "array-key" {
3417+
return "int|string".to_string();
3418+
}
3419+
// If the resolved type is a known scalar that PHP coerces
3420+
// to a key type, map it. Otherwise fall through to the
3421+
// default.
3422+
if key_str == "mixed" {
3423+
return "int|string".to_string();
3424+
}
3425+
// For anything else (e.g. a class-string<T>, or a union),
3426+
// return as-is if it is composed entirely of int/string
3427+
// subtypes; otherwise fall back.
3428+
return key_str;
3429+
}
3430+
3431+
"int|string".to_string()
3432+
}
3433+
3434+
/// Returns `true` when the type string represents a PHP type that
3435+
/// is always coerced to `int` when used as an array key.
3436+
fn is_int_like_key(ty: &str) -> bool {
3437+
matches!(
3438+
ty,
3439+
"int"
3440+
| "float"
3441+
| "bool"
3442+
| "true"
3443+
| "false"
3444+
| "null"
3445+
| "positive-int"
3446+
| "negative-int"
3447+
| "non-negative-int"
3448+
| "non-positive-int"
3449+
| "non-zero-int"
3450+
)
3451+
}
3452+
32903453
// ── Array function type preservation helpers ─────────────────────────
32913454

32923455
/// Extract the first positional argument expression from an

0 commit comments

Comments
 (0)