Skip to content

Commit 2693b66

Browse files
committed
Preserve generic arguments in callable param inference
1 parent 137226f commit 2693b66

6 files changed

Lines changed: 295 additions & 48 deletions

File tree

docs/CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6868
- **Diagnostics now work for vendor files open in the editor.** Projects using `--prefer-source` or monorepo setups where libraries are edited from the `vendor/` directory no longer have diagnostics suppressed. Previously all vendor files were unconditionally skipped, hiding syntax errors, deprecation warnings, and all other diagnostics. The `analyze` command also accepts vendor paths as explicit overrides (e.g. `phpantom_lsp analyze vendor/some-package/src/`).
6969
- **Full-line diagnostic suppression no longer requires a code mapping.** Full-line diagnostics (from tools like PHPStan that only report line numbers) are now suppressed whenever any precise diagnostic exists on the same line, regardless of error codes. Previously suppression relied on a hand-maintained mapping between error codes from different tools, which was incomplete and allowed redundant full-line underlines to obscure the precise location of the issue.
7070

71-
- **Scope methods on Eloquent Builder no longer produce false-positive diagnostics.** When a scope method's return type was a bare `Builder` (without generic arguments), subsequent calls in the chain lost the model type and reported scope methods as not found. Bare `Builder` return types on scope methods are now automatically wrapped as `Builder<ConcreteModel>` to preserve the chain. Additionally, unknown method diagnostics on the Eloquent Builder are suppressed when `__call` is present, since Builder dispatches scope methods and Query\Builder calls through it at runtime.
71+
- **Scope methods on Eloquent Builder no longer produce false-positive diagnostics.** When a scope method's return type was a bare `Builder` (without generic arguments), subsequent calls in the chain lost the model type and reported scope methods as not found. Bare `Builder` return types on scope methods are now automatically wrapped as `Builder<ConcreteModel>` to preserve the chain.
7272
- **Hover on closure parameters lost inferred generic arguments.** When a closure parameter had an explicit bare class type hint (e.g. `function (Builder $q)`) and the callable signature inferred the same class with generic arguments (e.g. `Builder<Article>`), hover displayed the bare class name instead of the generic version. The inferred type string is now preserved through the resolution pipeline so that hover, completion, and diagnostics all see the same type.
7373
- **Hover on variables assigned from chained methods lost generic parameters.** When a method returned `static`, `$this`, or `self` and the receiver carried generic parameters (e.g. `Builder<Article>`), hover on a variable assigned from the chain showed the bare class name (`Builder`) instead of the generic version. The receiver's type string is now carried through method call resolution so that self-referencing return types preserve generic parameters across chains.
7474
- **Hover on the `$` sign of a variable at its assignment site showed no type.** Hovering on the `$` of `$order = new Order()` displayed a bare `$order` with no type information, while hovering one character to the right on `o` worked correctly. The variable's byte offset coincided with the assignment statement's start offset, causing the resolver to skip the assignment entirely. The cursor offset is now nudged past the statement boundary so the assignment is included.
75-
- **Method chains through `__call` no longer lose the return type.** When a class defines `__call` and an unrecognized method is called (e.g. dynamic `whereColumn()` on an Eloquent Builder), the return type of `__call` is now used as a fallback. If `__call` returns `$this`, `static`, or `self`, the chain type is preserved so subsequent calls continue resolving. Previously the type was lost at the first dynamic call, breaking every link after it.
75+
- **Method chains through `__call` no longer lose the return type.** When a class defines `__call` and an unrecognized method is called (e.g. dynamic `whereColumn()` on an Eloquent Builder), the return type of `__call` is now used as a fallback. If `__call` returns `$this`, `static`, or `self`, the chain type is preserved so subsequent calls continue resolving. Previously the type was lost at the first dynamic call, breaking every link after it. Eloquent Builder's `__call` is patched from `mixed` to `static` during resolution, matching its runtime behavior (scope dispatch, macro dispatch, and Query\Builder forwarding all return the Builder instance).
76+
- **Callable parameter inference now preserves generic arguments from the receiver.** When a closure parameter is typed as a bare supertype (e.g. `fn(Builder $q)`) but the enclosing method's callable signature provides a more specific type via `$this` or `static` (e.g. `callable($this)`), the inferred type now carries the receiver's generic arguments. For example, calling `->when($flag, fn(Builder $q) => $q->active())` on a `Builder<Product>` chain now infers `$q` as `Builder<Product>`, so model-specific scope methods resolve correctly instead of being flagged as unknown.
7677
- **Null narrowing from `!== null` checks in conditions.** When a null-initialized variable was guarded by `$var !== null` in an `if` or `while` condition, the variable still showed `null` in its type inside the condition's `&&` operands and inside the then-body. The `!== null` check (and `!is_null()`, bare truthy guards) now narrows away `null` both for subsequent `&&` operands and inside the corresponding body block. Chained conditions like `$a !== null && $b !== null && $a->method()` narrow all checked variables. Conditions wrapped inside ternary expressions and return statements are also handled.
7778
- **Variables assigned inside `if`/`while` conditions now resolve in the body.** `if ($admin = AdminUser::first())` and `while ($row = nextRow())` now register the assignment so the variable has a type inside the loop or branch body. Assignments wrapped in comparisons like `if (($conn = getConn()) !== null)` are also recognized.
7879
- **Fluent chains only flag the first broken link.** In a chain like `$m->callHome()->callMom()->callDad()` where `callHome` does not exist, only `callHome` is flagged. Previously every subsequent link received its own "cannot verify" warning, burying the root cause in noise. Separate statements on the same variable (`$m->callHome(); $m->callMom();`) still flag independently. Scalar member access chains (`$user->getAge()->value->deep`) flag only the first scalar break. Null-safe, static, and mixed-operator chains are all handled.

src/completion/resolver.rs

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -900,19 +900,9 @@ fn resolve_call_raw_return_type(
900900
}
901901
}
902902
// __call fallback: method not found, or virtual method
903-
// without a return type.
904-
if let Some(m) = merged
905-
.methods
906-
.iter()
907-
.find(|m| m.name.eq_ignore_ascii_case("__call"))
908-
&& let Some(ref ret) = m.return_type
909-
{
910-
return Some(ret.to_string());
911-
}
912-
// __call fallback: when the method is not found but the
913-
// class defines __call, use its return type so that
914-
// chains through dynamic calls (e.g. Builder where{Column})
915-
// preserve the type.
903+
// without a return type. Use __call's return type so
904+
// that chains through dynamic calls (e.g. Builder
905+
// where{Column}) preserve the type.
916906
if let Some(m) = merged
917907
.methods
918908
.iter()

src/completion/variable/closure_resolution.rs

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1594,16 +1594,23 @@ fn infer_callable_params_from_receiver(
15941594

15951595
let params = find_callable_params_on_classes(&receiver_classes, method_name, arg_idx, ctx);
15961596

1597-
// Replace `$this` / `static` tokens with the receiver class FQN
1597+
// Replace `$this` / `static` tokens with the receiver's full type
15981598
// so that `resolve_closure_params_with_inferred` resolves them
15991599
// against the declaring class rather than the user's current class.
1600+
//
1601+
// When the receiver is a generic class whose template params have
1602+
// already been substituted (e.g. `Builder<Product>`), we need to
1603+
// reconstruct the full generic type so that the inferred callable
1604+
// param type carries the generic args. Without this, `$this` on
1605+
// `Builder<Product>` would degrade to bare `Builder`, losing the
1606+
// model type and preventing scope method resolution.
16001607
let result = if let Some(receiver) = receiver_classes.first() {
1601-
let receiver_fqn = receiver.fqn();
1608+
let receiver_type = build_receiver_self_type(receiver, ctx.class_loader);
16021609
params
16031610
.into_iter()
16041611
.map(|ty| {
16051612
crate::php_type::PhpType::parse(&ty)
1606-
.replace_self(&receiver_fqn)
1613+
.replace_self_with_type(&receiver_type)
16071614
.to_string()
16081615
})
16091616
.collect()
@@ -1807,6 +1814,94 @@ fn try_relation_query_override(
18071814
/// `Builder<Model>`, extract the model from the Builder's method return
18081815
/// types (which contain the substituted generic arg, e.g.
18091816
/// `Builder<Brand>` → `Brand`).
1817+
/// Build a `PhpType` representing the receiver class for `$this`/`static`
1818+
/// replacement in callable parameter inference.
1819+
///
1820+
/// For most classes this returns `PhpType::Named(fqn)`. For classes
1821+
/// whose template parameters have been concretely substituted (detected
1822+
/// by scanning method return types for generic signatures), the full
1823+
/// generic type is reconstructed. For example, an Eloquent
1824+
/// `Builder<Product>` receiver produces
1825+
/// `PhpType::Generic("Illuminate\\Database\\Eloquent\\Builder", [Named("App\\Product")])`
1826+
/// instead of a bare `PhpType::Named("Illuminate\\Database\\Eloquent\\Builder")`.
1827+
///
1828+
/// This preserves generic args through callable param inference so that
1829+
/// `callable($this)` on `Builder<Product>` infers `Builder<Product>`,
1830+
/// not bare `Builder`.
1831+
fn build_receiver_self_type(
1832+
receiver: &ClassInfo,
1833+
_class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
1834+
) -> PhpType {
1835+
let fqn = receiver.fqn();
1836+
1837+
// Only attempt reconstruction when the class declares template
1838+
// params — otherwise there are no generic args to recover.
1839+
if receiver.template_params.is_empty() {
1840+
return PhpType::Named(fqn);
1841+
}
1842+
1843+
// For Eloquent Builder, extract the model name from method return
1844+
// types where generic substitution has already been applied.
1845+
if (receiver.name == "Builder" || fqn == ELOQUENT_BUILDER_FQN)
1846+
&& let Some(model_fqn) = extract_model_from_builder(receiver)
1847+
{
1848+
return PhpType::Generic(
1849+
ELOQUENT_BUILDER_FQN.to_string(),
1850+
vec![PhpType::Named(model_fqn)],
1851+
);
1852+
}
1853+
1854+
// General case: try to recover concrete generic args from method
1855+
// return types that reference the class itself with generic params.
1856+
// For example, if a `Collection<int, User>` has a method returning
1857+
// `Collection<int, User>`, we can extract `[int, User]` as the
1858+
// concrete args.
1859+
if let Some(args) = extract_generic_args_from_methods(receiver, &fqn) {
1860+
return PhpType::Generic(fqn, args);
1861+
}
1862+
1863+
// Fallback: if we have a parent class with @extends generics and
1864+
// only one template param, try to extract from the parent chain.
1865+
// This covers cases like Relation<TRelatedModel> subclasses.
1866+
if !receiver.extends_generics.is_empty() && receiver.template_params.len() == 1 {
1867+
for (_, args) in &receiver.extends_generics {
1868+
if let Some(first_arg) = args.first() {
1869+
let arg_str = first_arg.to_string();
1870+
// Skip raw template param names that weren't substituted.
1871+
if !receiver.template_params.contains(&arg_str) {
1872+
return PhpType::Generic(fqn, vec![first_arg.clone()]);
1873+
}
1874+
}
1875+
}
1876+
}
1877+
1878+
PhpType::Named(fqn)
1879+
}
1880+
1881+
/// Try to extract concrete generic args from a class's own methods.
1882+
///
1883+
/// Scans method return types for `ClassName<Arg1, Arg2, ...>` patterns
1884+
/// where the base name matches the class, and the args are concrete
1885+
/// (not raw template param names).
1886+
fn extract_generic_args_from_methods(class: &ClassInfo, class_fqn: &str) -> Option<Vec<PhpType>> {
1887+
let class_short = crate::util::short_name(class_fqn);
1888+
for method in &class.methods {
1889+
if let Some(PhpType::Generic(base, args)) = &method.return_type {
1890+
let base_short = crate::util::short_name(base);
1891+
if (base == class_fqn || base_short.eq_ignore_ascii_case(class_short))
1892+
&& !args.is_empty()
1893+
&& args.iter().all(|a| {
1894+
let s = a.to_string();
1895+
!class.template_params.contains(&s)
1896+
})
1897+
{
1898+
return Some(args.clone());
1899+
}
1900+
}
1901+
}
1902+
None
1903+
}
1904+
18101905
fn find_model_from_receivers(
18111906
receiver_classes: &[Arc<ClassInfo>],
18121907
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,

src/diagnostics/unknown_members.rs

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ use crate::Backend;
5959
use crate::completion::resolver::{ResolutionCtx, SubjectOutcome, resolve_subject_outcome};
6060
use crate::symbol_map::SymbolKind;
6161
use crate::types::{AccessKind, ClassInfo, ClassLikeKind};
62-
use crate::virtual_members::laravel::ELOQUENT_BUILDER_FQN;
6362
use crate::virtual_members::resolve_class_fully_cached;
6463

6564
use super::helpers::{find_innermost_enclosing_class, make_diagnostic};
@@ -635,27 +634,6 @@ impl Backend {
635634
.iter()
636635
.any(|c| has_magic_method_for_access(c, is_static, true)));
637636

638-
// ── Suppress diagnostics on Eloquent Builder with __call ────
639-
// Eloquent Builder's `__call` specifically dispatches scope
640-
// methods defined on the concrete model and forwards unknown
641-
// calls to Query\Builder. When the Builder is used without
642-
// generic args (e.g. a closure parameter typed as bare
643-
// `Builder`), scope methods cannot be injected because the
644-
// model type is unknown. Emitting a warning for every scope
645-
// call on a bare Builder creates pervasive false positives in
646-
// real Laravel projects. Suppress the diagnostic entirely
647-
// when any resolved branch is an Eloquent Builder that has
648-
// `__call`, since the method WILL succeed at runtime.
649-
if has_magic_call
650-
&& resolved_classes.iter().any(|c| {
651-
c.name == "Builder"
652-
&& c.file_namespace.as_deref() == Some("Illuminate\\Database\\Eloquent")
653-
|| c.name == ELOQUENT_BUILDER_FQN
654-
})
655-
{
656-
return MemberCheckResult::Ok;
657-
}
658-
659637
// ── Member is unresolved on ALL branches — emit diagnostic ──
660638
let range = match offset_range_to_lsp_range(content, start as usize, end as usize) {
661639
Some(r) => r,

src/virtual_members/mod.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ use crate::inheritance::{
6464
use crate::php_type::PhpType;
6565
use crate::types::{ClassInfo, ConstantInfo, MethodInfo, PropertyInfo};
6666
use crate::util::short_name;
67+
use crate::virtual_members::laravel::ELOQUENT_BUILDER_FQN;
6768

6869
/// Cache key for [`ResolvedClassCache`]: fully-qualified class name
6970
/// paired with the concrete generic type arguments used at this
@@ -830,6 +831,20 @@ fn resolve_class_fully_inner(
830831
}
831832
}
832833

834+
// ── Eloquent Builder __call return type override ────────────────
835+
// Laravel's `Builder::__call()` is declared as returning `mixed`,
836+
// but in practice it always returns `$this`: scope dispatch,
837+
// macro dispatch, and Query\Builder forwarding all return the
838+
// Builder instance. The `mixed` docblock breaks method chains
839+
// because the resolver cannot recover a class type from `mixed`.
840+
// Override the return type to `static` so that chains through
841+
// unknown methods (e.g. scope calls on a bare `Builder` without
842+
// generic args) preserve the Builder type and downstream methods
843+
// like `get()`, `pluck()`, `first()` continue to resolve.
844+
if fqn == ELOQUENT_BUILDER_FQN {
845+
patch_eloquent_builder_call_return_type(&mut merged);
846+
}
847+
833848
// ── Cache store ─────────────────────────────────────────────────
834849
let result = Arc::new(merged);
835850
if let Some(cache) = cache {
@@ -839,6 +854,29 @@ fn resolve_class_fully_inner(
839854
result
840855
}
841856

857+
/// Override `__call` and `__callStatic` return types on Eloquent Builder
858+
/// from `mixed` to `static`.
859+
///
860+
/// Builder's `__call` dispatches to scope methods (`callNamedScope`),
861+
/// macros, and `Query\Builder` forwarding — all of which return `$this`.
862+
/// The `@return mixed` docblock is a PHP limitation; the actual return
863+
/// type is always the Builder instance. Patching this here means every
864+
/// consumer of the resolved Builder (completion, diagnostics, hover)
865+
/// automatically gets correct chain continuation through unknown methods.
866+
fn patch_eloquent_builder_call_return_type(class: &mut ClassInfo) {
867+
let static_type = PhpType::Named("static".to_string());
868+
for method in class.methods.make_mut().iter_mut() {
869+
if (method.name == "__call" || method.name == "__callStatic")
870+
&& method
871+
.return_type
872+
.as_ref()
873+
.is_some_and(|rt| rt.to_string() == "mixed")
874+
{
875+
method.return_type = Some(static_type.clone());
876+
}
877+
}
878+
}
879+
842880
/// Merge resolved interface members into a class, applying `@implements`
843881
/// generic substitutions.
844882
///

0 commit comments

Comments
 (0)