Skip to content

Commit 4329efe

Browse files
committed
Implement bidirectional template inference from closure param types
1 parent a0cc936 commit 4329efe

8 files changed

Lines changed: 777 additions & 40 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
- **Eloquent `$dates` array.** Legacy models using `protected $dates = [...]` now get `Carbon\Carbon`-typed virtual properties for each listed column. Explicit `$casts` entries take priority when both define the same column.
1818
- **`fix` CLI subcommand.** `phpantom_lsp fix` applies automated code fixes across a project, modeled after php-cs-fixer. Specify rules with `--rule unused_import` (multiple allowed) or omit to run all preferred native fixers. `--dry-run` reports what would change without writing files (exit code 2). `--with-phpstan` enables PHPStan-based fixers (not yet implemented). The first shipped rule, `unused_import`, removes all unused `use` statements project-wide, handling simple imports, group imports, and blank-line collapsing. Supports path filtering (`fix src/`) and single-file mode.
1919
- **Array element type extraction from property generics.** Bracket access on properties annotated with generic array types (e.g. `$this->cache[$key]->` where `cache` is `array<string, IntCollection>`, or `$this->translations[0]->` where `translations` is `Collection<int, Translation>`) now resolves the element type correctly. Previously the generic parameters were lost during property chain resolution, causing "subject type could not be resolved" on any member access after the bracket. Works for `$this->prop`, `$obj->prop`, nested chains like `$this->nested->prop[0]->`, string-literal keys, and method chains after the bracket access.
20-
- **Template binding from closure return types.** Methods like `Collection::reduce()` that declare a method-level `@template` parameter bound through a callable's return type now resolve correctly. When the closure or arrow function argument has a return type annotation (e.g. `fn(Decimal $carry, $item): Decimal => ...`), the return type binds the template parameter, so the method's own return type resolves to the concrete class. This works for `fn()` arrow functions, `function()` closures, and closures with `use()` clauses.
20+
- **Bidirectional template inference from closures.** When a method or function declares a `@template` parameter that appears in a callable's signature, the template is inferred from both the closure's return type and its parameter types. Return-type binding (e.g. `@param callable(...): T` with `fn(...): Decimal => ...` infers `T` as `Decimal`) was already supported. Parameter-type binding is new: `@param Closure(T): void` with `function(User $u): void { ... }` now infers `T` as `User` from the closure's parameter type annotation. Positional matching is supported, so `@param Closure(int, T): void` correctly picks `T` from the second parameter. When the same template appears in both the callable's return type and parameters, the return type takes priority. Works for methods, standalone functions, and constructors with `fn()` arrow functions and `function()` closures.
2121
- **Inherited docblock type propagation.** When a child class overrides a method from a parent class or interface without providing its own `@return` or `@param` docblock, the ancestor's richer types now flow through automatically. An interface declaring `@return list<Pen>` with a native `: array` hint propagates `list<Pen>` to any implementor that only declares `: array`. The same applies to parent class methods, parameter types (matched by position so renamed parameters still inherit), and property type hints. Descriptions and return descriptions are also inherited when the child lacks them. If the child provides its own docblock type, it is respected as an intentional override.
2222
- **Drupal project support.** Drupal projects are detected via `composer.json` (`drupal/core`, `drupal/core-recommended`, or `drupal/core-dev`). The web root is resolved from `extra.drupal-scaffold.locations.web-root` with a filesystem fallback. Drupal-specific directories (`core`, `modules/contrib`, `modules/custom`, `themes/contrib`, `themes/custom`, `profiles`, `sites`) are scanned with `.gitignore` bypassed so that Composer-managed code is always indexed. Drupal PHP extensions (`.module`, `.install`, `.theme`, `.profile`, `.inc`, `.engine`) are recognized as PHP source. Contributed by @syntlyx in https://github.com/AJenbo/phpantom_lsp/pull/52.
2323
- **`@phpstan-assert-if-true $this` narrowing.** Instance methods annotated with `@phpstan-assert-if-true` or `@phpstan-assert-if-false` targeting `$this` now narrow the receiver variable in the corresponding branch. For example, `if ($app->isTestApp())` narrows `$app` to the asserted subtype inside the then-body. Contributed by @syntlyx in https://github.com/AJenbo/phpantom_lsp/pull/52.

docs/todo.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ within the same impact tier.
2424
| # | Item | Impact | Effort |
2525
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | ----------- |
2626
| T23 | [`class-string<T>` static method dispatch](todo/type-inference.md#t23-class-stringt-static-method-dispatch) | Medium | Medium |
27-
| T21 | [Bidirectional template inference](todo/type-inference.md#t21-bidirectional-template-inference-upperlower-bounds) (closure return type → generic) | Medium | Medium-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 |
3029
| | **Release 0.7.0** | | |

docs/todo/type-inference.md

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -515,40 +515,6 @@ and resolve the method on that class. When the return type contains
515515

516516
---
517517

518-
## T21. Bidirectional template inference (upper/lower bounds)
519-
**Impact: Medium · Effort: Medium-High**
520-
521-
PHPantom's template resolution only tracks one direction: matching
522-
template parameters against concrete types from extends clauses and
523-
direct argument positions. PHPStan and Psalm track both upper bounds
524-
(covariant positions like return types) and lower bounds (contravariant
525-
positions like parameter types).
526-
527-
Key gaps:
528-
529-
1. When `@param Closure(T): void $callback` receives a closure, `T`
530-
should be inferred from the closure's parameter type
531-
(contravariant).
532-
2. When multiple bounds exist for the same template, the most specific
533-
one should win. Psalm uses `appearance_depth` to prefer direct
534-
bindings over nested ones.
535-
3. No variance tracking. `@template-covariant T` vs `@template T`
536-
affects whether `Container<Cat>` is assignable to
537-
`Container<Animal>`.
538-
4. Template parameters not instantiated from closure return type at
539-
call sites. Example: `Collection::reduce(fn(Decimal $c, ...):
540-
Decimal => ..., new Decimal('0'))``TReduceReturnType` should
541-
be inferred as `Decimal` from the callback return type, and
542-
`TReduceInitial` as `Decimal` from the `$initial` argument.
543-
Currently the return type of `reduce()` is unresolved.
544-
(Triage: `FlowService:517`.)
545-
546-
**Implementation:** add a `TemplateResolution` struct that accumulates
547-
lower and upper bounds during call-site analysis, with a `resolve()`
548-
method that picks the most specific bound.
549-
550-
---
551-
552518
## T24. `stdClass` dynamic property access
553519
**Impact: Low-Medium · Effort: Low**
554520

example.php

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,35 @@ function(Pen $carry, Pencil $item): Pen {
573573
}
574574
}
575575

576+
// ── Closure Param → Template Inference (Contravariant) ─────────────────────
577+
578+
class ClosureParamTemplateDemo
579+
{
580+
public function demo(): void
581+
{
582+
// When a method declares @param Closure(T): void $cb, the template
583+
// param T is inferred from the closure's *parameter* type annotation
584+
// (contravariant position), not the return type.
585+
586+
$bus = new ScaffoldingEventBus();
587+
588+
// Arrow function: T inferred as Pen from fn(Pen $p)
589+
$result = $bus->listen(function(Pen $p): void { $p->write(); });
590+
$result->write(); // T = Pen
591+
$result->color(); // completions for Pen
592+
593+
// Full closure: T inferred as User from function(User $u)
594+
$user = $bus->listen(function(User $u): void { $u->getEmail(); });
595+
$user->getName(); // T = User
596+
597+
// Second param position: @param Closure(int, T): void
598+
$proc = new ScaffoldingBatchProcessor();
599+
$item = $proc->process(function(int $i, Pencil $p): void { $p->sketch(); });
600+
$item->sketch(); // T = Pencil (from position 1)
601+
$item->sharpen();
602+
}
603+
}
604+
576605

577606
// ── Trait Generic Substitution ──────────────────────────────────────────────
578607

@@ -3625,6 +3654,38 @@ class ScaffoldingClosureParamInference
36253654
public function __construct() { $this->items = new FluentCollection([new Pen('red'), new Pen('blue')]); }
36263655
}
36273656

3657+
class ScaffoldingEventBus
3658+
{
3659+
/**
3660+
* @template T
3661+
* @param Closure(T): void $callback
3662+
* @return T
3663+
*/
3664+
public function listen(Closure $callback): mixed
3665+
{
3666+
$params = (new \ReflectionFunction($callback))->getParameters();
3667+
$type = $params[0]->getType();
3668+
$class = $type ? $type->getName() : 'stdClass';
3669+
return (new \ReflectionClass($class))->newInstanceWithoutConstructor();
3670+
}
3671+
}
3672+
3673+
class ScaffoldingBatchProcessor
3674+
{
3675+
/**
3676+
* @template T
3677+
* @param Closure(int, T): void $handler
3678+
* @return T
3679+
*/
3680+
public function process(Closure $handler): mixed
3681+
{
3682+
$params = (new \ReflectionFunction($handler))->getParameters();
3683+
$type = $params[1]->getType();
3684+
$class = $type ? $type->getName() : 'stdClass';
3685+
return (new \ReflectionClass($class))->newInstanceWithoutConstructor();
3686+
}
3687+
}
3688+
36283689
class ScaffoldingTemplateCallableHolder
36293690
{
36303691
/** @var array<int, Pen> */
@@ -5163,6 +5224,19 @@ function runDemoAssertions(): void
51635224
);
51645225
assert($reduced instanceof Pen, 'reduce() with fn(): Pen must return Pen');
51655226

5227+
// ── ScaffoldingEventBus::listen() — closure param type binding ──────
5228+
$bus = new ScaffoldingEventBus();
5229+
$listened = $bus->listen(function(Pen $p): void { $p->write(); });
5230+
assert($listened instanceof Pen, 'listen(fn(Pen $p)) must return Pen (T inferred from closure param)');
5231+
5232+
$listenedUser = $bus->listen(function(User $u): void { $u->getEmail(); });
5233+
assert($listenedUser instanceof User, 'listen(function(User $u)) must return User');
5234+
5235+
// ── ScaffoldingBatchProcessor::process() — second closure param ─────
5236+
$proc = new ScaffoldingBatchProcessor();
5237+
$processed = $proc->process(function(int $i, Pencil $p): void { $p->sketch(); });
5238+
assert($processed instanceof Pencil, 'process(fn(int, Pencil)) must return Pencil (T from position 1)');
5239+
51665240
// ── Nested generic: ServiceLocator::wrap → Box::unwrap ──────────────
51675241
$boxed = $locator->wrap(Pen::class);
51685242
assert($boxed instanceof Box, 'ServiceLocator::wrap() must return Box');

src/completion/call_resolution.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1226,6 +1226,17 @@ impl Backend {
12261226
subs.insert(tpl_name.clone(), PhpType::parse(&ret_type));
12271227
}
12281228
}
1229+
TemplateBindingMode::CallableParamType(position) => {
1230+
// `@param Closure(T): void $cb` — extract the closure's
1231+
// parameter type annotation at the given position.
1232+
if let Some(param_type) =
1233+
super::source::helpers::extract_closure_param_type_from_text(
1234+
arg_text, position,
1235+
)
1236+
{
1237+
subs.insert(tpl_name.clone(), PhpType::parse(&param_type));
1238+
}
1239+
}
12291240
TemplateBindingMode::ArrayElement => {
12301241
if let Some(type_name) = Self::resolve_arg_text_to_type(arg_text, ctx) {
12311242
subs.insert(tpl_name.clone(), PhpType::parse(&type_name));

0 commit comments

Comments
 (0)