Skip to content

Commit 877fd64

Browse files
committed
correct $this / static for callable
1 parent 3c6a91d commit 877fd64

5 files changed

Lines changed: 290 additions & 51 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5454
- **`@var` without variable name for array access.** `/** @var array<int, Customer> */` followed by `$thing = []; $thing[0]->` now resolves the element type. Previously `find_iterable_raw_type_in_source` only matched annotations that explicitly named the variable (e.g. `/** @var array<int, Customer> $thing */`). The scanner now also matches no-variable-name `@var` annotations when the line immediately following the docblock is an assignment to the target variable. Works with `array<K, V>`, `list<V>`, `array<V>`, and all other generic iterable forms.
5555
- **Inferred element type from array literals.** `$thing = [new Customer()]; $thing[0]->` now resolves to `Customer`. When an array literal contains positional (non-keyed) entries like `new ClassName()`, the element types are inferred and combined into a `list<Type>` annotation. Previously only string-keyed entries (for array shapes) and push-style assignments (`$var[] = expr`) were tracked; positional entries were silently discarded. Mixed arrays with both keyed and positional entries infer the positional elements as `list<>` when no string keys are present.
5656
- **Inline array literal with index access.** `[Customer::first()][0]->` now resolves to the element type. Subject extraction recognises inline array literals (bracket pairs without a `$var` prefix) and produces a structured subject. The resolver splits the literal from the index segments, resolves each comma-separated element via call-chain or `new` expression resolution, and returns the element classes. The inline-literal handler runs before the enum-case/static-member check so that subjects containing `::` inside brackets are not misinterpreted.
57+
- **`$this` in callable parameter types resolves to receiver class.** When a method signature uses `$this` or `static` in a callable parameter type (e.g. `@param callable($this, mixed): $this`), inferred closure parameters now resolve to the receiver class rather than the class the user is editing. Previously, `Builder::when(true, function ($query) { $query-> })` inside a controller would infer `$query` as the controller class. Now it correctly resolves to the Builder. Works for instance method calls, static method calls, and arrow functions.
5758
- **Inline array element function calls.** `end($customers)->`, `current($this->users)->`, and similar inline uses of array element-extracting functions (`end`, `current`, `reset`, `next`, `prev`, `array_pop`, `array_shift`) now resolve to the element type. Previously this only worked when the result was assigned to a variable (`$last = end($customers); $last->`). The resolver now detects known array functions in the inline call path, resolves the first argument's iterable type (supporting plain variables with docblock/assignment scanning, call chains like `Customer::get()->all()`, static calls, and property access), extracts the generic element type, and returns the corresponding class.
5859
- **Generator TSend inference inside nested control flow.** `$var = yield $expr` inside `while`, `if`, `foreach`, or other nested blocks now correctly resolves `$var` to the TSend type from the enclosing generator's `@return Generator<TKey, TValue, TSend, TReturn>` annotation. Previously `find_enclosing_return_type` was called with the cursor offset, so its backward brace scan stopped at the innermost block's `{` (e.g. the `if`'s opening brace) instead of reaching the function's opening brace. The call now uses the method/function body's opening brace offset directly from the AST, which is immune to intermediate control-flow braces. Yield-based TValue inference (`yield $var`) was already unaffected because the text-based body scan operates on the full function body.
5960
- **Callable parameter type parsing in union types.** `extract_callable_param_types` now handles callable signatures wrapped in unions like `(Closure(Builder<TModel>): mixed)|null`, `callable(Collection<int, TValue>, int): mixed|null`, and `null|callable(Order): bool`. Previously the parser treated the entire union as one token and failed to find the callable signature. The `split_type_token` tokenizer was also fixed to keep generic suffixes like `Collection<int, User>|null` and parenthesized groups like `(Closure(X): Y)|null` as single tokens instead of splitting at the closing `>`, `}`, or `)`. This enables closure parameter inference for Laravel methods like `Builder::whereHas()`, `BuildsQueries::chunk()`, and `Builder::with()`, where the callback parameter types use these union patterns.

docs/todo-laravel.md

Lines changed: 13 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -73,43 +73,7 @@ benefit) and an **Effort** estimate (implementation complexity):
7373

7474
---
7575

76-
#### 1. `$this` in inferred callable parameter types resolves to wrong class
77-
78-
| | |
79-
|---|---|
80-
| **Impact** | ★★ — Manifests only when closure params are untyped; most IDE-aware codebases type-hint explicitly. Affects `when()`, `tap()`, and similar higher-order Eloquent/Collection methods. |
81-
| **Effort** | ★ — Replace literal `$this`/`static` tokens with the receiver's FQN in `infer_callable_params_from_receiver` before returning. |
82-
83-
When a closure parameter is untyped and the inference system extracts
84-
callable param types from the called method's signature, `$this` in
85-
the extracted type resolves to the **calling class** (the class
86-
containing the user's code) instead of the class that declares the
87-
method.
88-
89-
```php
90-
// Builder::when() signature (from Conditionable trait):
91-
// @param callable($this, mixed): $this $callback
92-
93-
// In a controller:
94-
User::when($active, function ($query) {
95-
$query-> // $query inferred as Controller, not Builder<User>
96-
});
97-
```
98-
99-
The callable param types are extracted as raw strings by
100-
`extract_callable_param_types`. When `$this` appears in these
101-
strings, `resolve_closure_params_with_inferred` passes them to
102-
`type_hint_to_classes`, which resolves `$this` relative to
103-
`ctx.current_class` — the class the user is editing, not the class
104-
that owns the method.
105-
106-
**Where to change:** In `infer_callable_params_from_receiver` (and
107-
the static variant), after extracting callable param types, replace
108-
any literal `$this` or `static` tokens with the FQN of the receiver
109-
class before returning them. This ensures the inferred types
110-
reference the declaring class rather than the calling class.
111-
112-
#### 2. `*_count` relationship count properties
76+
#### 1. `*_count` relationship count properties
11377

11478
| | |
11579
|---|---|
@@ -135,7 +99,7 @@ again and push a `{snake_name}_count` property typed as `int` for
13599
each one. The property should have lower priority than explicit
136100
`@property` tags.
137101

138-
#### 3. `#[Scope]` attribute (Laravel 11+)
102+
#### 2. `#[Scope]` attribute (Laravel 11+)
139103

140104
| | |
141105
|---|---|
@@ -165,7 +129,7 @@ methods with the `#[Scope]` attribute the same as `scopeX` methods
165129
(strip the first `$query` parameter, expose as both static and
166130
instance virtual methods).
167131

168-
#### 4. `$dates` array (deprecated)
132+
#### 3. `$dates` array (deprecated)
169133

170134
| | |
171135
|---|---|
@@ -184,7 +148,7 @@ Merge these into `casts_definitions` at a lower priority than explicit
184148
`$casts` entries, or add a separate field on `ClassInfo` and handle
185149
priority in the provider.
186150

187-
#### 5. Custom Eloquent builders (`HasBuilder` / `#[UseEloquentBuilder]`)
151+
#### 4. Custom Eloquent builders (`HasBuilder` / `#[UseEloquentBuilder]`)
188152

189153
| | |
190154
|---|---|
@@ -222,7 +186,7 @@ declares a custom builder via `@use HasBuilder<X>` in `use_generics`
222186
or a `newEloquentBuilder()` method with a non-default return type.
223187
If found, load and resolve that builder class instead.
224188

225-
#### 6. `abort_if`/`abort_unless` type narrowing
189+
#### 5. `abort_if`/`abort_unless` type narrowing
226190

227191
| | |
228192
|---|---|
@@ -266,7 +230,7 @@ to subsequent code:
266230
This is similar to the existing guard clause narrowing but triggered
267231
by specific function names rather than `if` + early return.
268232

269-
#### 7. `collect()` and other helper functions lose generic type info
233+
#### 6. `collect()` and other helper functions lose generic type info
270234

271235
| | |
272236
|---|---|
@@ -314,7 +278,7 @@ before passing it to `type_hint_to_classes`. See the general TODO
314278
item (§ PHP Language Feature Gaps, "Function-level `@template`
315279
generic resolution") for the full implementation plan.
316280

317-
#### 8. Factory `has*`/`for*` relationship methods
281+
#### 7. Factory `has*`/`for*` relationship methods
318282

319283
| | |
320284
|---|---|
@@ -352,7 +316,7 @@ The `has*` variant should accept optional `int $count` and
352316
`array|callable $state` parameters; `for*` should accept
353317
`array|callable $state`.
354318

355-
#### 9. `$pivot` property on BelongsToMany related models
319+
#### 8. `$pivot` property on BelongsToMany related models
356320

357321
| | |
358322
|---|---|
@@ -398,7 +362,7 @@ the `BelongsToMany` relationship stubs. If the user's stub set
398362
includes these annotations, it already works through our PHPDoc
399363
provider.
400364

401-
#### 10. `withSum()` / `withAvg()` / `withMin()` / `withMax()` aggregate properties
365+
#### 9. `withSum()` / `withAvg()` / `withMin()` / `withMax()` aggregate properties
402366

403367
| | |
404368
|---|---|
@@ -414,7 +378,7 @@ aggregate function (`withSum`/`withAvg` → `float`,
414378

415379
The `@property` workaround applies here too.
416380

417-
#### 11. Higher-order collection proxies
381+
#### 10. Higher-order collection proxies
418382

419383
| | |
420384
|---|---|
@@ -437,7 +401,7 @@ and `HigherOrderCollectionProxyExtension`, which resolve the proxy's
437401
template types and delegate property/method lookups to the collection's
438402
value type.
439403

440-
#### 12. `SoftDeletes` trait methods on Builder
404+
#### 11. `SoftDeletes` trait methods on Builder
441405

442406
| | |
443407
|---|---|
@@ -465,7 +429,7 @@ type — e.g. `Builder<static>` instead of `Builder<User>`. This is
465429
a minor gap but not worth a dedicated fix until custom builder
466430
support (gap §7) is implemented.
467431

468-
#### 13. `View::withX()` and `RedirectResponse::withX()` dynamic methods
432+
#### 12. `View::withX()` and `RedirectResponse::withX()` dynamic methods
469433

470434
| | |
471435
|---|---|
@@ -497,7 +461,7 @@ hard-coding the two known classes. A simpler approach: add
497461
`@method` tags to bundled stubs for the most common dynamic `with*`
498462
methods, or document this as a known limitation.
499463

500-
#### 14. `$appends` array
464+
#### 13. `$appends` array
501465

502466
| | |
503467
|---|---|

example.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1259,6 +1259,15 @@ public function demo(): void
12591259
BlogAuthor::whereHas('posts', function ($query) {
12601260
$query->where('published', true); // resolves to Builder
12611261
});
1262+
1263+
// $this in callable param resolves to receiver, not current class
1264+
$pipeline = new ScaffoldingPipeline();
1265+
$pipeline->when(true, function ($pipe) {
1266+
$pipe->send('data'); // resolves to ScaffoldingPipeline, not this demo class
1267+
});
1268+
1269+
// Arrow function variant
1270+
$pipeline->tap(fn($p) => $p->through([]));
12621271
}
12631272
}
12641273

@@ -1793,6 +1802,24 @@ class ScaffoldingClosureParamInference
17931802
public FluentCollection $items;
17941803
}
17951804

1805+
class ScaffoldingPipeline
1806+
{
1807+
/**
1808+
* @param callable($this, mixed): $this $callback
1809+
* @return $this
1810+
*/
1811+
public function when(bool $condition, callable $callback): static { return $this; }
1812+
1813+
/**
1814+
* @param callable($this): void $callback
1815+
* @return $this
1816+
*/
1817+
public function tap(callable $callback): static { return $this; }
1818+
1819+
public function send(mixed $data): static { return $this; }
1820+
public function through(array $pipes): static { return $this; }
1821+
}
1822+
17961823
class ScaffoldingFirstClassCallable
17971824
{
17981825
public function dispatch(): Pen

src/completion/closure_resolution.rs

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -662,7 +662,17 @@ impl Backend {
662662
let rctx = ctx.as_resolution_ctx();
663663
let receiver_classes = Self::resolve_target_classes(obj_text, AccessKind::Arrow, &rctx);
664664

665-
Self::find_callable_params_on_classes(&receiver_classes, method_name, arg_idx, ctx)
665+
let params =
666+
Self::find_callable_params_on_classes(&receiver_classes, method_name, arg_idx, ctx);
667+
668+
// Replace `$this` / `static` tokens with the receiver class FQN
669+
// so that `resolve_closure_params_with_inferred` resolves them
670+
// against the declaring class rather than the user's current class.
671+
if let Some(receiver) = receiver_classes.first() {
672+
Self::replace_self_references(params, &receiver.name)
673+
} else {
674+
params
675+
}
666676
}
667677

668678
/// Infer callable parameter types for a closure passed at position
@@ -689,12 +699,75 @@ impl Backend {
689699
});
690700
if let Some(ref cls) = owner {
691701
let resolved = Self::resolve_class_fully(cls, ctx.class_loader);
692-
Self::find_callable_params_on_method(&resolved, method_name, arg_idx, ctx)
702+
let params = Self::find_callable_params_on_method(&resolved, method_name, arg_idx, ctx);
703+
Self::replace_self_references(params, &cls.name)
693704
} else {
694705
vec![]
695706
}
696707
}
697708

709+
/// Replace `$this` and `static` tokens in inferred callable parameter
710+
/// type strings with the given class FQN. This ensures that when a
711+
/// method signature uses `$this` or `static` (e.g.
712+
/// `callable($this): $this`), the inferred types reference the
713+
/// declaring/receiver class rather than the class the user is editing.
714+
fn replace_self_references(params: Vec<String>, class_fqn: &str) -> Vec<String> {
715+
params
716+
.into_iter()
717+
.map(|ty| {
718+
// Replace whole-word occurrences of `$this` and `static`.
719+
// These appear as standalone type tokens in callable
720+
// signatures, e.g. `callable($this, mixed): $this`.
721+
let result = ty.as_str();
722+
// Fast path: nothing to replace.
723+
if !result.contains("$this") && !result.contains("static") {
724+
return ty;
725+
}
726+
let mut out = String::with_capacity(result.len());
727+
let mut rest = result;
728+
while !rest.is_empty() {
729+
if let Some(pos) = rest.find("$this") {
730+
// Check that it's a word boundary (not part of a
731+
// longer identifier).
732+
let after = pos + 5;
733+
let is_boundary =
734+
after >= rest.len() || !rest.as_bytes()[after].is_ascii_alphanumeric();
735+
if is_boundary {
736+
out.push_str(&rest[..pos]);
737+
out.push_str(class_fqn);
738+
rest = &rest[after..];
739+
continue;
740+
}
741+
// Not a boundary — consume past this occurrence.
742+
out.push_str(&rest[..after]);
743+
rest = &rest[after..];
744+
continue;
745+
}
746+
if let Some(pos) = rest.find("static") {
747+
let before_ok =
748+
pos == 0 || !rest.as_bytes()[pos - 1].is_ascii_alphanumeric();
749+
let after = pos + 6;
750+
let after_ok =
751+
after >= rest.len() || !rest.as_bytes()[after].is_ascii_alphanumeric();
752+
if before_ok && after_ok {
753+
out.push_str(&rest[..pos]);
754+
out.push_str(class_fqn);
755+
rest = &rest[after..];
756+
continue;
757+
}
758+
out.push_str(&rest[..after]);
759+
rest = &rest[after..];
760+
continue;
761+
}
762+
// No more occurrences.
763+
out.push_str(rest);
764+
break;
765+
}
766+
out
767+
})
768+
.collect()
769+
}
770+
698771
/// Search for the method `method_name` on each of `classes` and
699772
/// extract callable parameter types at `arg_idx`.
700773
fn find_callable_params_on_classes(

0 commit comments

Comments
 (0)