Skip to content

Commit 935a5e6

Browse files
committed
Fix template inference for Collection::reduce() with closure in call
chains
1 parent 108c175 commit 935a5e6

7 files changed

Lines changed: 539 additions & 104 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
### Fixed
1111

12+
- **Template inference from closures in fluent call chains.** When a method like `Collection::reduce()` takes a closure argument and the result is chained directly (e.g. `->reduce(fn(Decimal $c): Decimal => ..., new Decimal())->add(...)`), the template return type now resolves correctly. Previously the symbol map serialized closure arguments as empty text, so the diagnostic engine saw `reduce()` with no arguments and could not infer `TReduceReturnType` from the closure's return type annotation. All call arguments are now preserved in the subject text, including synthetic signatures for closures and arrow functions.
1213
- **`instanceof` narrowing with unresolvable target class.** When the `instanceof` target class cannot be loaded (e.g. it lives inside a phar archive), the variable's type is now treated as unknown instead of keeping the un-narrowed base type. This eliminates false-positive "unknown member" diagnostics for members that only exist on the narrowed subclass. Applies to all narrowing contexts: if-bodies, ternary expressions, `assert()` statements, and inline `&&` chains.
1314
- **`DB::select()` return type.** `DB::select()`, `DB::selectFromWriteConnection()`, and `DB::selectResultSets()` now return `array<int, stdClass>` instead of bare `array`, and `DB::selectOne()` returns `?stdClass` instead of `mixed`. Property access on query results (e.g. `$result[0]->column_name`) no longer produces false-positive diagnostics. The same fix applies to the underlying `Illuminate\Database\Connection` class.
1415
- **Redis `Connection` method resolution.** `Illuminate\Redis\Connections\Connection` now has `@mixin \Redis` applied automatically, so Redis commands like `del()`, `get()`, `set()`, etc. resolve through the phpredis stubs instead of producing false-positive diagnostics.

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-
| B12 | [`Collection::reduce()` generic return type not inferred](todo/bugs.md#b12-collectionreduce-generic-return-type-not-inferred) | Low | Medium |
2726
| B13 | [Array shape tracking from keyed literal assignments in loops](todo/bugs.md#b13-array-shape-tracking-from-keyed-literal-assignments-in-loops) | Low | High |
2827
| H10 | [`return.unusedType` — remove unused type from return union](todo/phpstan-actions.md#h10-returnunusedtype--remove-unused-type-from-return-union) | Medium | Medium |
2928
| H6 | `return.type` — update return type to match actual returns | Medium | Medium |

docs/todo/bugs.md

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,5 @@
11
# PHPantom — Bug Fixes
22

3-
## B12. `Collection::reduce()` generic return type not inferred
4-
**Impact: Low · Effort: Medium**
5-
6-
Pattern:
7-
```php
8-
$result = $collection
9-
->reduce(fn(Decimal $carry, OrderProduct $p): Decimal => $carry->add($p->price), new Decimal('0'))
10-
->add($total); // unresolved
11-
```
12-
13-
The `reduce()` method on Laravel collections has this signature:
14-
```
15-
@template TReduceInitial
16-
@template TReduceReturnType
17-
@param callable(TReduceInitial|TReduceReturnType, TValue, TKey): TReduceReturnType $callback
18-
@param TReduceInitial $initial
19-
@return TReduceReturnType
20-
```
21-
22-
PHPantom should infer:
23-
- `TReduceInitial = Decimal` (from the `$initial` argument)
24-
- `TReduceReturnType = Decimal` (from the closure return type hint)
25-
- Therefore `reduce()` returns `Decimal`
26-
27-
The bidirectional template inference (`4329efe`) partially addresses
28-
this, but `reduce()` still returns unresolved. The likely gap is the
29-
union `TReduceInitial|TReduceReturnType` in the callable's first
30-
parameter position: the inference engine may not decompose the union
31-
to extract individual template bindings when both templates appear
32-
in the same callable parameter type.
33-
34-
**Observed in:** `FlowService:517` — `->reduce(fn(Decimal $c, ...):
35-
Decimal => ..., new Decimal('0'))->add($total)`.
36-
37-
---
38-
393
## B13. Array shape tracking from keyed literal assignments in loops
404
**Impact: Low · Effort: High**
415

example.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,10 @@ function(Pen $carry, Pencil $item): Pen {
570570
new Pen('starter')
571571
);
572572
$merged2->color(); // TReduceReturnType = Pen
573+
574+
// Chained call: reduce() result used directly without intermediate variable.
575+
// The template inference must survive the symbol-map subject text serialization.
576+
$pencils->reduce(fn(Pen $carry, Pencil $item): Pen => $carry, new Pen('starter'))->write();
573577
}
574578
}
575579

@@ -5266,6 +5270,10 @@ function runDemoAssertions(): void
52665270
);
52675271
assert($reduced instanceof Pen, 'reduce() with fn(): Pen must return Pen');
52685272

5273+
// Chained call: reduce() result used directly without intermediate variable.
5274+
$chainedWrite = $reducible->reduce(fn(Pen $carry, Pencil $item): Pen => $carry, new Pen('starter'))->write();
5275+
assert(is_string($chainedWrite), 'reduce()->write() chained must return string (Pen::write() return type)');
5276+
52695277
// ── ScaffoldingEventBus::listen() — closure param type binding ──────
52705278
$bus = new ScaffoldingEventBus();
52715279
$listened = $bus->listen(function(Pen $p): void { $p->write(); });

src/symbol_map/extraction.rs

Lines changed: 118 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -2454,7 +2454,7 @@ fn expr_to_subject_text(expr: &Expression<'_>) -> String {
24542454
Expression::Call(Call::Method(mc)) => {
24552455
let obj = expr_to_subject_text(mc.object);
24562456
if let ClassLikeMemberSelector::Identifier(ident) = &mc.method {
2457-
let args_text = format_first_class_arg(&mc.argument_list.arguments);
2457+
let args_text = format_all_call_args(&mc.argument_list.arguments);
24582458
format!("{}->{}({})", obj, ident.value, args_text)
24592459
} else {
24602460
format!("{}->?()", obj)
@@ -2463,7 +2463,7 @@ fn expr_to_subject_text(expr: &Expression<'_>) -> String {
24632463
Expression::Call(Call::NullSafeMethod(mc)) => {
24642464
let obj = expr_to_subject_text(mc.object);
24652465
if let ClassLikeMemberSelector::Identifier(ident) = &mc.method {
2466-
let args_text = format_first_class_arg(&mc.argument_list.arguments);
2466+
let args_text = format_all_call_args(&mc.argument_list.arguments);
24672467
format!("{}?->{}({})", obj, ident.value, args_text)
24682468
} else {
24692469
format!("{}?->?()", obj)
@@ -2472,15 +2472,15 @@ fn expr_to_subject_text(expr: &Expression<'_>) -> String {
24722472
Expression::Call(Call::StaticMethod(sc)) => {
24732473
let class_text = expr_to_subject_text(sc.class);
24742474
if let ClassLikeMemberSelector::Identifier(ident) = &sc.method {
2475-
let args_text = format_first_class_arg(&sc.argument_list.arguments);
2475+
let args_text = format_all_call_args(&sc.argument_list.arguments);
24762476
format!("{}::{}({})", class_text, ident.value, args_text)
24772477
} else {
24782478
format!("{}::?()", class_text)
24792479
}
24802480
}
24812481
Expression::Call(Call::Function(fc)) => {
24822482
let func_text = expr_to_subject_text(fc.function);
2483-
let args_text = format_first_class_arg(&fc.argument_list.arguments);
2483+
let args_text = format_all_call_args(&fc.argument_list.arguments);
24842484
// When the callee is a parenthesized expression (e.g.
24852485
// `($this->formatter)()`), wrap the inner text back in
24862486
// parens so that `SubjectExpr::parse` sees
@@ -2594,77 +2594,128 @@ fn expr_to_subject_text(expr: &Expression<'_>) -> String {
25942594
}
25952595
}
25962596

2597-
/// Format the first argument of a call expression as source text.
2597+
/// Format all arguments of a call expression as a comma-separated string.
25982598
///
2599-
/// Preserves `Foo::class`, string/integer/float literals, `null`,
2600-
/// `true`, `false`, and `$variable` references so that conditional
2601-
/// return-type resolution (e.g. `$guard is null ? Factory : Guard`)
2602-
/// can inspect the argument value. Returns an empty string when the
2603-
/// first argument cannot be represented as simple text.
2604-
fn format_first_class_arg(args: &TokenSeparatedSequence<'_, Argument<'_>>) -> String {
2605-
if let Some(first) = args.iter().next() {
2606-
let arg_expr = match first {
2599+
/// Each argument is serialized to a text representation that preserves
2600+
/// enough information for downstream consumers:
2601+
/// - Conditional return-type resolution needs the first argument value
2602+
/// (`Foo::class`, string literals, `null`, etc.)
2603+
/// - Template parameter inference needs closure/arrow-function signatures
2604+
/// (parameter types and return type) and constructor calls (`new Foo()`)
2605+
///
2606+
/// When an argument cannot be represented, it is emitted as `...` so that
2607+
/// positional indices remain correct for template binding resolution.
2608+
fn format_all_call_args(args: &TokenSeparatedSequence<'_, Argument<'_>>) -> String {
2609+
let mut parts = Vec::new();
2610+
for arg in args.iter() {
2611+
let arg_expr = match arg {
26072612
Argument::Positional(pos) => pos.value,
26082613
Argument::Named(named) => named.value,
26092614
};
2610-
match arg_expr {
2611-
// Foo::class
2612-
Expression::Access(Access::ClassConstant(cca)) => {
2613-
if let ClassLikeConstantSelector::Identifier(ident) = &cca.constant
2614-
&& ident.value == "class"
2615-
{
2616-
let class_text = expr_to_subject_text(cca.class);
2617-
return format!("{}::class", class_text);
2618-
}
2619-
}
2620-
// String literals: 'web', "guard"
2621-
Expression::Literal(Literal::String(lit_str)) => {
2622-
return lit_str.raw.to_string();
2623-
}
2624-
// Integer literals: 0, 42
2625-
Expression::Literal(Literal::Integer(lit_int)) => {
2626-
return lit_int.raw.to_string();
2627-
}
2628-
// Float literals: 3.14
2629-
Expression::Literal(Literal::Float(lit_float)) => {
2630-
return lit_float.raw.to_string();
2631-
}
2632-
// null
2633-
Expression::Literal(Literal::Null(_)) => {
2634-
return "null".to_string();
2635-
}
2636-
// true
2637-
Expression::Literal(Literal::True(_)) => {
2638-
return "true".to_string();
2639-
}
2640-
// false
2641-
Expression::Literal(Literal::False(_)) => {
2642-
return "false".to_string();
2643-
}
2644-
// $variable
2645-
Expression::Variable(Variable::Direct(dv)) => {
2646-
return dv.name.to_string();
2647-
}
2648-
// new ClassName(…) → "new ClassName()"
2649-
Expression::Instantiation(inst) => {
2650-
let class_text = expr_to_subject_text(inst.class);
2651-
if !class_text.is_empty() {
2652-
return format!("new {}()", class_text);
2653-
}
2615+
let text = format_arg_expr(arg_expr);
2616+
parts.push(text);
2617+
}
2618+
// Trim trailing `...` placeholders so that simple single-arg calls
2619+
// (the common case) don't produce `method(Foo::class, ...)`.
2620+
while parts.last().is_some_and(|p| p == "...") {
2621+
parts.pop();
2622+
}
2623+
parts.join(", ")
2624+
}
2625+
2626+
/// Format a single argument expression to text.
2627+
///
2628+
/// Handles the same cases as the old `format_first_class_arg` plus
2629+
/// closure and arrow-function expressions. For closures the full body
2630+
/// is replaced with a placeholder (`=> ...` or `{ ... }`) to keep the
2631+
/// subject text compact while preserving parameter types and return
2632+
/// type annotations that template inference depends on.
2633+
fn format_arg_expr(expr: &Expression<'_>) -> String {
2634+
match expr {
2635+
// Foo::class
2636+
Expression::Access(Access::ClassConstant(cca)) => {
2637+
if let ClassLikeConstantSelector::Identifier(ident) = &cca.constant
2638+
&& ident.value == "class"
2639+
{
2640+
let class_text = expr_to_subject_text(cca.class);
2641+
return format!("{}::class", class_text);
2642+
}
2643+
"...".to_string()
2644+
}
2645+
// String literals: 'web', "guard"
2646+
Expression::Literal(Literal::String(lit_str)) => lit_str.raw.to_string(),
2647+
// Integer literals: 0, 42
2648+
Expression::Literal(Literal::Integer(lit_int)) => lit_int.raw.to_string(),
2649+
// Float literals: 3.14
2650+
Expression::Literal(Literal::Float(lit_float)) => lit_float.raw.to_string(),
2651+
// null
2652+
Expression::Literal(Literal::Null(_)) => "null".to_string(),
2653+
// true
2654+
Expression::Literal(Literal::True(_)) => "true".to_string(),
2655+
// false
2656+
Expression::Literal(Literal::False(_)) => "false".to_string(),
2657+
// $variable
2658+
Expression::Variable(Variable::Direct(dv)) => dv.name.to_string(),
2659+
// new ClassName(…) → "new ClassName()"
2660+
Expression::Instantiation(inst) => {
2661+
let class_text = expr_to_subject_text(inst.class);
2662+
if class_text.is_empty() {
2663+
"...".to_string()
2664+
} else {
2665+
format!("new {}()", class_text)
26542666
}
2655-
// Any other expression (property access, method calls,
2656-
// static access, array access, etc.) — delegate to the
2657-
// general subject text formatter so that callers like
2658-
// `ARRAY_ELEMENT_FUNCS` resolution see the full argument.
2659-
_ => {
2660-
let text = expr_to_subject_text(arg_expr);
2661-
if !text.is_empty() {
2662-
return text;
2663-
}
2667+
}
2668+
// Arrow function: fn(Type $a, Type $b): ReturnType => …
2669+
// Serialize the signature so template inference can extract
2670+
// parameter types and the return type annotation.
2671+
Expression::ArrowFunction(arrow) => {
2672+
let params = format_callable_params(&arrow.parameter_list);
2673+
let ret = arrow
2674+
.return_type_hint
2675+
.as_ref()
2676+
.map(|rth| format!(": {}", crate::parser::extract_hint_string(&rth.hint)))
2677+
.unwrap_or_default();
2678+
format!("fn({}){} => ...", params, ret)
2679+
}
2680+
// Closure: function(Type $a, Type $b): ReturnType { … }
2681+
Expression::Closure(closure) => {
2682+
let params = format_callable_params(&closure.parameter_list);
2683+
let ret = closure
2684+
.return_type_hint
2685+
.as_ref()
2686+
.map(|rth| format!(": {}", crate::parser::extract_hint_string(&rth.hint)))
2687+
.unwrap_or_default();
2688+
format!("function({}){} {{ ... }}", params, ret)
2689+
}
2690+
// Any other expression — delegate to the general subject text
2691+
// formatter. Falls back to `...` when it can't be represented.
2692+
_ => {
2693+
let text = expr_to_subject_text(expr);
2694+
if text.is_empty() {
2695+
"...".to_string()
2696+
} else {
2697+
text
26642698
}
26652699
}
26662700
}
2667-
String::new()
2701+
}
2702+
2703+
/// Format a callable's parameter list as a comma-separated string of
2704+
/// `Type $name` pairs, preserving type annotations for template inference.
2705+
fn format_callable_params(params: &FunctionLikeParameterList<'_>) -> String {
2706+
let mut parts = Vec::new();
2707+
for param in params.parameters.iter() {
2708+
let name = param.variable.name.to_string();
2709+
let type_text = param
2710+
.hint
2711+
.as_ref()
2712+
.map(|h| crate::parser::extract_hint_string(h));
2713+
match type_text {
2714+
Some(t) => parts.push(format!("{} {}", t, name)),
2715+
None => parts.push(name),
2716+
}
2717+
}
2718+
parts.join(", ")
26682719
}
26692720

26702721
/// Check whether `expr` is an `assert(… instanceof …)` call.

0 commit comments

Comments
 (0)