phpantom_lsp 0.7.0

Fast PHP language server with deep type intelligence. Generics, Laravel, PHPStan annotations. Ready in an instant.
Documentation
//! Centralized Laravel class patch system.
//!
//! After virtual members are applied during [`resolve_class_fully_inner`],
//! certain Laravel classes need post-resolution fixups that cannot be
//! expressed as virtual member providers (which add new members) but
//! instead modify existing members' type information.
//!
//! This module provides a single entry point, [`apply_laravel_patches`],
//! that dispatches to per-class patch functions based on the fully-qualified
//! class name.  All Laravel-specific class mutations live here, making it
//! easy to audit and extend the patch inventory.
//!
//! ## Patch inventory
//!
//! 1. **`Eloquent\Builder::__call` / `__callStatic` return type.**
//!    Overrides the `mixed` return type to `static` so that method chains
//!    through unknown calls (scope dispatch, macro dispatch, Query\Builder
//!    forwarding) preserve the Builder type.
//!
//! 2. **`Conditionable::when()` / `unless()` return type.**
//!    The trait declares `@return $this|TWhenReturnType` (or a conditional
//!    form in Larastan stubs).  The unresolved `TWhenReturnType` template
//!    parameter breaks `is_self_like_type` checks, degrading Builder chains.
//!    The patch replaces the return type with `$this` so that chained
//!    `when()` / `unless()` calls preserve the receiver type.
//!
//! 3. **Bare `Builder` return types on scope methods** are handled
//!    separately in `scopes.rs` (`is_bare_builder_type`) because that
//!    patch runs at scope-injection time (post-generic-substitution),
//!    not during `resolve_class_fully_inner`.  It is documented here
//!    as part of the patch inventory but not dispatched from this module.
//!
//! 4. **`Redis\Connections\Connection` mixin.**
//!    The base `Connection` class delegates all Redis commands to the
//!    underlying `\Redis` client via `__call`, but lacks a `@mixin`
//!    annotation.  The patch injects `@mixin \Redis` **pre-resolution**
//!    (in `resolve_class_fully_inner`, before virtual member providers
//!    run) so that `collect_mixin_members` picks it up and merges
//!    `del()`, `get()`, `set()`, etc. from the stubs.  This patch is
//!    not dispatched from `apply_laravel_patches` because that runs
//!    post-resolution, after mixin collection has already completed.
//!
//! 5. **`DB` facade / `Connection` select method return types.**
//!    The facade's `@method` annotations and the underlying
//!    `Connection` class both declare `select()`,
//!    `selectFromWriteConnection()`, and `selectResultSets()` as
//!    returning bare `array`.  The actual return type is
//!    `array<int, stdClass>`.  Similarly, `selectOne()` is declared as
//!    `mixed` but actually returns `stdClass|null`.  The patch
//!    overrides these return types so that downstream property access
//!    on query results resolves correctly.

use crate::php_type::PhpType;
use crate::types::ClassInfo;

use super::ELOQUENT_BUILDER_FQN;

/// FQN of the `Conditionable` trait from `illuminate/support`.
const CONDITIONABLE_FQN: &str = "Illuminate\\Support\\Traits\\Conditionable";

/// FQN of the `DB` facade from `illuminate/support`.
const DB_FACADE_FQN: &str = "Illuminate\\Support\\Facades\\DB";

/// FQN of the base `Connection` class from `illuminate/database`.
const DB_CONNECTION_FQN: &str = "Illuminate\\Database\\Connection";

/// Apply all registered Laravel class patches to a fully-resolved class.
///
/// Called from [`resolve_class_fully_inner`] after virtual members have
/// been merged and before the result is cached.  Dispatches to per-class
/// patch functions based on `fqn`.
///
/// This is also applied transitively: when a class uses the
/// `Conditionable` trait, the trait's `when()` / `unless()` methods are
/// merged into the class.  The patch scans the merged method list by
/// name, so it fixes the return type regardless of whether the method
/// was inherited from the trait directly or through a parent class.
pub fn apply_laravel_patches(class: &mut ClassInfo, fqn: &str) {
    if fqn == ELOQUENT_BUILDER_FQN {
        patch_eloquent_builder_call_return_type(class);
        // Builder uses Conditionable, so patch when/unless too.
        patch_conditionable_when_unless(class);
    } else if fqn == CONDITIONABLE_FQN || class_uses_conditionable(class) {
        patch_conditionable_when_unless(class);
    }

    if fqn == DB_FACADE_FQN || fqn == DB_CONNECTION_FQN {
        patch_db_select_return_types(class);
    }
}

/// Override `__call` and `__callStatic` return types on Eloquent Builder
/// from `mixed` to `static`.
///
/// Builder's `__call` dispatches to scope methods (`callNamedScope`),
/// macros, and `Query\Builder` forwarding — all of which return `$this`.
/// The `@return mixed` docblock is a PHP limitation; the actual return
/// type is always the Builder instance.  Patching this here means every
/// consumer of the resolved Builder (completion, diagnostics, hover)
/// automatically gets correct chain continuation through unknown methods.
fn patch_eloquent_builder_call_return_type(class: &mut ClassInfo) {
    let static_type = PhpType::static_();
    for method in class.methods.make_mut().iter_mut() {
        if (method.name == "__call" || method.name == "__callStatic")
            && method.return_type.as_ref().is_some_and(|rt| rt.is_mixed())
        {
            method.return_type = Some(static_type.clone());
        }
    }
}

/// Patch `when()` and `unless()` return types to `$this`.
///
/// The `Conditionable` trait declares these methods with return types
/// like `$this|TWhenReturnType` or the Larastan conditional form
/// `(TWhenReturnType is void|null ? $this : TWhenReturnType)`.  In
/// either case the unresolved method-level template parameter
/// `TWhenReturnType` / `TUnlessReturnType` prevents `is_self_like_type`
/// from recognizing the return as self-referential, which breaks method
/// chain resolution on Builder and Collection.
///
/// Since we cannot currently bind method-level templates during chain
/// resolution, the pragmatic fix is to treat these methods as returning
/// `$this` unconditionally.  This matches the common case (the callback
/// returns void and the method returns the receiver) and preserves
/// chain continuation.
fn patch_conditionable_when_unless(class: &mut ClassInfo) {
    let this_type = PhpType::this();
    for method in class.methods.make_mut().iter_mut() {
        if method.name != "when" && method.name != "unless" {
            continue;
        }
        let dominated_by_template = match &method.return_type {
            Some(rt) => return_type_has_unresolved_template(rt),
            None => false,
        };
        if dominated_by_template {
            method.return_type = Some(this_type.clone());
        }
    }
}

/// Check whether a return type contains an unresolved template parameter
/// that would prevent `is_self_like_type` from matching.
///
/// Recognizes patterns like:
/// - `$this|TWhenReturnType` (union with an unknown non-self member)
/// - `TWhenReturnType` (bare template parameter)
/// - `static|TWhenReturnType` (union mixing self-like and template)
///
/// A type name is considered a template parameter if it starts with an
/// uppercase `T` followed by another uppercase letter, or if it is not
/// a known keyword / built-in type and is not fully-qualified (no `\`).
fn return_type_has_unresolved_template(ty: &PhpType) -> bool {
    match ty {
        PhpType::Union(members) => members.iter().any(is_likely_template_param),
        other => is_likely_template_param(other),
    }
}

/// Heuristic: does this type look like an unresolved template parameter?
///
/// Template parameters in PHPDoc are typically `TFoo` (uppercase T + more).
/// We also catch any single bare name that is not a PHP keyword, not
/// fully-qualified, and not a self-reference.
fn is_likely_template_param(ty: &PhpType) -> bool {
    let name = match ty {
        PhpType::Named(n) => n.as_str(),
        _ => return false,
    };

    // PHP built-in / keyword types (includes self, static, $this, parent).
    if crate::php_type::is_keyword_type(name) {
        return false;
    }

    // FQN references (contain `\`) are concrete classes, not template params.
    if name.contains('\\') {
        return false;
    }

    // Common Conditionable template param names.
    if name == "TWhenReturnType" || name == "TUnlessReturnType" {
        return true;
    }

    // General heuristic: starts with T followed by an uppercase letter.
    if name.len() >= 2 {
        let mut chars = name.chars();
        if let (Some('T'), Some(c)) = (chars.next(), chars.next())
            && c.is_ascii_uppercase()
        {
            return true;
        }
    }

    false
}

/// Patch `select()`, `selectFromWriteConnection()`, `selectResultSets()`
/// return types from bare `array` to `array<int, stdClass>`, and
/// `selectOne()` from `mixed` to `stdClass|null`.
///
/// Both the `DB` facade (`@method` annotations) and the underlying
/// `Illuminate\Database\Connection` class declare these methods with
/// imprecise return types.  The actual runtime return is always an
/// array of `stdClass` rows (or a single `stdClass|null` for
/// `selectOne`).  Patching this here lets property access on query
/// results resolve correctly across the codebase.
fn patch_db_select_return_types(class: &mut ClassInfo) {
    let std_class = PhpType::Named("stdClass".to_owned());
    let array_of_std = PhpType::generic_array(PhpType::int(), std_class.clone());
    let std_or_null = PhpType::Nullable(Box::new(std_class));

    for method in class.methods.make_mut().iter_mut() {
        match method.name.as_str() {
            "select" | "selectFromWriteConnection" | "selectResultSets" => {
                if method
                    .return_type
                    .as_ref()
                    .is_some_and(|rt| rt.is_bare_array())
                {
                    method.return_type = Some(array_of_std.clone());
                }
            }
            "selectOne" => {
                if method.return_type.as_ref().is_some_and(|rt| rt.is_mixed()) {
                    method.return_type = Some(std_or_null.clone());
                }
            }
            _ => {}
        }
    }
}

/// Check whether a class uses the `Conditionable` trait (directly or
/// through its trait list / parent chain markers).
///
/// We check `used_traits` for both the FQN and the short name since
/// trait usage may be recorded in either form depending on how the
/// source was parsed.
fn class_uses_conditionable(class: &ClassInfo) -> bool {
    class
        .used_traits
        .iter()
        .any(|t| t == CONDITIONABLE_FQN || t == "Conditionable" || t.ends_with("\\Conditionable"))
}

#[cfg(test)]
#[path = "patches_tests.rs"]
mod tests;