Skip to content

Commit 8b0b85d

Browse files
committed
Closure parameter inference
1 parent 5f93127 commit 8b0b85d

13 files changed

Lines changed: 1714 additions & 60 deletions

docs/CHANGELOG.md

Lines changed: 3 additions & 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+
- **Closure parameter inference.** When a closure or arrow function is passed to a method or function whose parameter is typed as `callable(T): R` or `Closure(T): R`, untyped closure parameters are now inferred from the callable signature. For example, `$users->map(fn($u) => $u->name)` infers `$u` as `User` when `map` declares `@param callable(TValue): TMapValue` and generic substitution has resolved `TValue` to `User`. Works with instance methods, static methods, null-safe calls, standalone functions, `$this->method()` receivers, and closures at any argument position. Explicit type hints on closure parameters always take precedence. Template parameters inside callable signatures (e.g. `callable(TValue): void`) are substituted during generic resolution so the inferred types are concrete.
1213
- **Factory support.** `User::factory()->create()` and `->make()` now resolve to the model class. When a model uses the `HasFactory` trait with an explicit `@use HasFactory<UserFactory>` annotation, the generics system handles resolution. When the annotation is absent, the naming convention is used as a fallback: `App\Models\User` maps to `Database\Factories\UserFactory`, and subdirectories are preserved (`App\Models\Admin\SuperUser` maps to `Database\Factories\Admin\SuperUserFactory`). Factory chain methods that return `static` (e.g. `count()`, `state()`) continue the chain on the factory, while `create()` and `make()` return the model. The convention also works in reverse: a factory class extending `Factory` without `@extends Factory<Model>` resolves `TModel` from its own class name. Both directions are implemented as fallbacks that defer to explicit generics when present.
1314
- **`newCollection()` override detection.** Eloquent models that override the `newCollection()` method now resolve to the custom collection class declared in the method's return type. This is the third detection mechanism alongside `#[CollectedBy]` and `@use HasCollection<X>`. Priority order: attribute, trait, method override. Works with short names resolved via `use` imports and fully-qualified return types. The standard `Collection` return type is correctly ignored. Custom collection methods appear after `->get()`, on relationship properties, and in builder chains.
1415
- **Body-inferred relationship properties.** Eloquent relationship methods that lack `@return` annotations now produce virtual properties by scanning the method body for patterns like `$this->hasMany(Post::class)`. The relationship type is inferred from the method name (`hasMany` to `HasMany`, `belongsTo` to `BelongsTo`, etc.) and the related model class is extracted from the first `::class` argument. Supports all 10 relationship types including `HasOneThrough`. Fully-qualified class names, extra foreign key arguments, and chained builder calls (e.g. `->latest()`) are handled. When both a `@return` annotation and a body pattern are present, the annotation takes priority. Projects that don't use Larastan no longer need to add annotations for basic relationship completion.
@@ -45,6 +46,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4546

4647
### Fixed
4748

49+
- **Arrow function parameter go-to-definition.** Go-to-definition on a variable used in an arrow function body (e.g. `$o` in `fn(Order $o) => $o->getItems()`) now jumps to the parameter on the same line. Previously the backward scan excluded the cursor line, so it would skip the arrow function parameter and jump to an unrelated earlier variable with the same name. The fix includes the cursor line in the scan but only accepts non-assignment definitions (parameters, foreach, catch, static/global) to preserve the existing behavior for reassignments like `$value = $value->value`. Go-to-definition on the parameter itself (the LHS `$o`) correctly resolves the type hint and jumps to the `Order` class.
50+
4851
- **Relationship property collection type uses the related model's custom collection.** When a model like `Product` had a `HasMany<Review, $this>` relationship, the virtual property `$product->reviews` incorrectly used the owning model's custom collection (`ProductCollection`) instead of the related model's (`ReviewCollection`). The provider now loads the related model via `class_loader` and reads its `custom_collection` field. The integration test was updated to use distinct collections per model so the bug is no longer masked.
4952

5053
- **UTF-8 boundary panic in `find_enclosing_return_type`.** Files containing multi-byte UTF-8 characters (such as `` in comment banners) could cause a panic when `extract_method_return_from_body_brace` sliced the content at a byte offset that landed inside a multi-byte character. This broke variable type resolution for any method whose body brace fell within 2000 bytes of such a character, causing `$model->` completions to silently return empty results while `$this->` (which uses a different code path) continued to work. The slice offset is now adjusted to a valid char boundary.

docs/todo-laravel.md

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# PHPantom — Laravel Support: Remaining Work
22

3-
> Last updated: 2025-07-21
3+
> Last updated: 2026-02-26
44
55
This document tracks bugs, known gaps, and missing features in
66
PHPantom's Laravel Eloquent support. For the general architecture and
@@ -10,18 +10,6 @@ virtual member provider design, see `ARCHITECTURE.md`.
1010

1111
## Missing features
1212

13-
### 1. Variable assignment from builder-forwarded static method in GTD
14-
15-
`$q = User::where(...)` then `$q->orderBy()` does not fully resolve for
16-
go-to-definition because the variable resolution path
17-
(`resolve_rhs_static_call`) finds `where()` on the raw `Task` class via
18-
`resolve_method_return_types_with_args`, which calls
19-
`resolve_class_fully` internally. The issue is that the returned Builder
20-
type's methods are resolved, but go-to-definition then cannot trace back
21-
to the declaring class in a Builder loaded through the chain. This
22-
works for completion (which only needs the type) but not for GTD (which
23-
needs the source location).
24-
2513
### 2. Closure parameter inference in collection pipelines
2614

2715
`$users->map(fn($u) => $u->...)` does not infer `$u` as the
@@ -44,21 +32,24 @@ Builder at resolution time without preserving the original declaration
4432
site. The GTD fallback `find_scope_on_builder_model` may not be
4533
triggering for the `with()` return path.
4634

47-
### 4. Multi-line chain after `with()` breaks completion and GTD
35+
### 4. Blank line inside a method chain breaks collapse logic
4836

49-
When the chain after `with()` is split across lines, neither completion
50-
nor go-to-definition works:
37+
When a blank line separates parts of a fluent chain,
38+
`collapse_continuation_lines` stops walking backwards at the empty line
39+
and never reaches the base expression:
5140

5241
```php
5342
Brand::with('english')
43+
5444
->paginate(); // neither GTD nor completion works
5545
```
5646

57-
The single-line equivalent resolves fine. This is a variant of the
58-
multi-line closure argument issue (now fixed): `collapse_continuation_lines`
59-
joins lines that start with `->`, but the base line `Brand::with('english')`
60-
is not being found when the chain spans lines in this specific pattern.
61-
Likely a cursor-position or line-counting edge case in the collapse logic.
47+
The same chain without the blank line works fine. The backward walk in
48+
`collapse_continuation_lines` treats any line that does not start with
49+
`->` or `?->` as the base. An empty line matches that condition, so the
50+
collapsed result is just `->paginate()` with no subject. Fixing this
51+
requires skipping blank (whitespace-only) lines during the backward
52+
walk.
6253

6354
---
6455

example.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1746,6 +1746,35 @@ public function intermediateVariable(): void
17461746
}
17471747

17481748

1749+
// ── Closure Parameter Inference ─────────────────────────────────────────────
1750+
1751+
class ClosureParamInferenceDemo
1752+
{
1753+
/** @var FluentCollection<int, User> */
1754+
public FluentCollection $users;
1755+
1756+
public function mapArrowFunction(): void
1757+
{
1758+
// $u is inferred as User from map's callable(TValue, TKey) signature.
1759+
$this->users->map(fn($u) => $u->getEmail());
1760+
}
1761+
1762+
public function eachClosure(): void
1763+
{
1764+
// $user is inferred as User from each's callable(TValue, TKey) signature.
1765+
$this->users->each(function ($user) {
1766+
$user->getEmail(); // resolves to User
1767+
});
1768+
}
1769+
1770+
public function explicitTypeWins(): void
1771+
{
1772+
// Explicit type hint takes precedence over inference.
1773+
$this->users->map(fn(Order $o) => $o->getItems());
1774+
}
1775+
}
1776+
1777+
17491778
// ═══════════════════════════════════════════════════════════════════════════
17501779
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
17511780
// ┃ SCAFFOLDING — Supporting definitions below this line. ┃
@@ -2550,6 +2579,14 @@ public function map(callable $callback)
25502579
{
25512580
}
25522581

2582+
/**
2583+
* @param callable(TValue, TKey): void $callback
2584+
* @return static<TKey, TValue>
2585+
*/
2586+
public function each(callable $callback)
2587+
{
2588+
}
2589+
25532590
/**
25542591
* Multi-line @return with nested generics spanning lines.
25552592
*

0 commit comments

Comments
 (0)