Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Static method calls resolve return types as accurately as instance calls.** `Foo::bar()` previously missed inference that `$foo->bar()` already had: return types behind a `@phpstan-type` alias, inherited return types substituted through a generic interface or trait, and the `__callStatic()` magic-method fallback. These now resolve the same way for both call styles.
- **Facade static calls keep concrete method return types.** Static calls on Laravel-style facades now resolve missing methods through `getFacadeAccessor()` and facade `@mixin` targets before falling back to `__callStatic()`, so values like `Driver::details()` keep the concrete provider method return type instead of degrading to the facade's broad magic-call return. Contributed by @calebdw.
- **`class-string<static>` parameters no longer reject sibling subclass constants.** Static helper calls from a shared base class that pass concrete `::class` constants for sibling subclasses no longer report false argument-type mismatches against `class-string<static>`. Contributed by @calebdw.
- **`class-string<static>` parameters now diagnose provably invalid class strings.** Passing an unrelated class to a `class-string<static>` parameter is now flagged as a type mismatch instead of being silently accepted. The diagnostic resolves `static` to the declaring class at the call site and checks whether the argument class is in the inheritance hierarchy, so child classes and siblings are still accepted while unrelated classes are rejected. Contributed by @calebdw.
- **`class-string<static>` parameters now diagnose provably invalid class strings.** Passing an unrelated class to a `class-string<static>` parameter is now flagged as a type mismatch instead of being silently accepted. The diagnostic resolves `static` to the declaring class at the call site and checks whether the argument class is in the inheritance hierarchy, so child classes and siblings are still accepted while unrelated classes are rejected. The type system now models `static` and `$this` as bounded types (`StaticType` and `ThisType`) that preserve late-static-binding semantics instead of flattening to a bare class name. Contributed by @calebdw.
- **Built-in PHP classes shadowed by vendor polyfills resolve to the real definition.** When an installed package ships a polyfill for a PHP built-in (for example symfony/polyfill-php84's `RoundingMode`), resolution sometimes picked the polyfill's legacy pre-enum declaration instead of the built-in, turning enum cases into plain int constants and reporting false "expects RoundingMode, got int" argument mismatches. Which declaration won could change from one run to the next, making whole-project analysis results nondeterministic. Global names of built-in classes now always resolve to the bundled PHP definition, and classes discovered inside phar archives are indexed in a stable order.
- **Member name positions no longer suggest classes.** Typing a name after `function`, `const`, or enum `case` (for example `protected function getC`) no longer offers unrelated class names from the project. Property names were already safe because they start with `$`. Contributed by @calebdw.
- **Null-initialized variables reassigned in an untyped foreach are not stuck as `null`.** When the iterable has no known element type (for example an untyped parameter), the loop value is now treated as `mixed`, so `$x = $value` after `$x = null` participates in post-loop merge and `is_null` early-return narrowing instead of leaving a false `null` type at later call sites. Contributed by @calebdw.
Expand Down
7 changes: 5 additions & 2 deletions src/code_actions/generate_constructor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,11 @@ fn collect_qualifying_properties<'a>(
/// intersections, array shapes, generics, etc.
fn is_simple_php_type(ty: &PhpType) -> bool {
match ty {
PhpType::Named(_) => true,
PhpType::Nullable(inner) => matches!(inner.as_ref(), PhpType::Named(_)),
PhpType::Named(_) | PhpType::StaticType(_) | PhpType::ThisType(_) => true,
PhpType::Nullable(inner) => matches!(
inner.as_ref(),
PhpType::Named(_) | PhpType::StaticType(_) | PhpType::ThisType(_)
),
_ => false,
}
}
Expand Down
30 changes: 16 additions & 14 deletions src/diagnostics/type_errors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -558,17 +558,17 @@ impl Backend {
None
};
class_part.and_then(|cp| {
let low = cp.to_ascii_lowercase();
match low.as_str() {
"self" | "static" | "$this" => {
find_innermost_enclosing_class(&file_ctx.classes, call_site.args_start)
.map(|c| c.fqn().to_string())
}
"parent" => {
find_innermost_enclosing_class(&file_ctx.classes, call_site.args_start)
.and_then(|c| c.parent_class.as_ref().map(|p| p.to_string()))
}
_ => class_loader(cp).map(|cls| cls.fqn().to_string()),
if cp.eq_ignore_ascii_case("self")
|| cp.eq_ignore_ascii_case("static")
|| cp.eq_ignore_ascii_case("$this")
{
find_innermost_enclosing_class(&file_ctx.classes, call_site.args_start)
.map(|c| c.fqn().to_string())
} else if cp.eq_ignore_ascii_case("parent") {
find_innermost_enclosing_class(&file_ctx.classes, call_site.args_start)
.and_then(|c| c.parent_class.as_ref().map(|p| p.to_string()))
} else {
class_loader(cp).map(|cls| cls.fqn().to_string())
}
})
};
Expand Down Expand Up @@ -637,7 +637,7 @@ impl Backend {
let effective_param_type = if param_type.contains_self_ref() {
if let Some(ref fqn) = call_context_class {
resolved_param =
param_type.resolve_self_refs(fqn, ctx_parent_fqn.as_deref());
param_type.resolve_self_refs_bounded(fqn, ctx_parent_fqn.as_deref());
&resolved_param
} else {
param_type
Expand Down Expand Up @@ -702,8 +702,10 @@ impl Backend {
let resolved_alt;
let effective_alt = if alt_type.contains_self_ref() {
if let Some(ref fqn) = call_context_class {
resolved_alt = alt_type
.resolve_self_refs(fqn, ctx_parent_fqn.as_deref());
resolved_alt = alt_type.resolve_self_refs_bounded(
fqn,
ctx_parent_fqn.as_deref(),
);
&resolved_alt
} else {
alt_type
Expand Down
2 changes: 2 additions & 0 deletions src/php_type/display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ impl fmt::Display for PhpType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PhpType::Named(s) => write!(f, "{s}"),
PhpType::StaticType(bound) => write!(f, "static({bound})"),
PhpType::ThisType(bound) => write!(f, "$this({bound})"),

PhpType::Nullable(inner) => write!(f, "?{inner}"),

Expand Down
38 changes: 36 additions & 2 deletions src/php_type/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,30 @@ pub enum PhpType {
/// never fed unbounded free text.
Named(Atom),

/// Late-static-binding type with a known lower bound.
///
/// Represents `static` or `$this` resolved in the context of a class:
/// "the runtime class, which is at least `bound`." Unlike
/// [`Named`](PhpType::Named) with the class FQN, this preserves the
/// polymorphic semantics of `static` — a `StaticType("Base")` is a
/// subtype of `Base` but is not the same as `Named("Base")` because it
/// could be any subclass at runtime.
///
/// Produced when `Named("static")` is resolved in a class context.
/// `self` is NOT represented here — it resolves to
/// `Named(declaring_class)` since it is invariant.
StaticType(Atom),

/// The `$this` type with a known lower bound.
///
/// More specific than [`StaticType`](PhpType::StaticType): represents
/// the exact runtime instance, not just "this class or a subclass."
/// `ThisType("Foo") <: StaticType("Foo") <: Named("Foo")`.
///
/// Important for fluent interfaces (`@return $this`) where the return
/// type must preserve the receiver's exact type through method chains.
ThisType(Atom),

/// Nullable type: `?T`.
Nullable(Box<PhpType>),

Expand Down Expand Up @@ -971,6 +995,9 @@ impl PhpType {
PhpType::Named(s) if !is_scalar_name(s) => {
Some(s.strip_prefix('\\').unwrap_or(s.as_str()))
}
PhpType::StaticType(s) | PhpType::ThisType(s) => {
Some(s.strip_prefix('\\').unwrap_or(s.as_str()))
}
PhpType::Generic(name, _) if !is_scalar_name(name) => {
Some(name.strip_prefix('\\').unwrap_or(name.as_str()))
}
Expand Down Expand Up @@ -998,7 +1025,9 @@ impl PhpType {
/// - `?T` → `?NativeT`
pub fn to_native_hint(&self) -> Option<String> {
match self {
PhpType::Named(s) => native_scalar_name(s).map(|n| n.to_string()),
PhpType::Named(s) | PhpType::StaticType(s) | PhpType::ThisType(s) => {
native_scalar_name(s).map(|n| n.to_string())
}
PhpType::Generic(name, _) => {
// Generic classes: strip the generic params.
// `array<K,V>` → `array`, `Collection<T>` → `Collection`
Expand Down Expand Up @@ -1051,7 +1080,9 @@ impl PhpType {
/// avoiding a parse round-trip.
pub fn to_native_hint_typed(&self) -> Option<PhpType> {
match self {
PhpType::Named(s) => native_scalar_name(s).map(|n| PhpType::Named(atom(n))),
PhpType::Named(s) | PhpType::StaticType(s) | PhpType::ThisType(s) => {
native_scalar_name(s).map(|n| PhpType::Named(atom(n)))
}
PhpType::Generic(name, _) => {
// Generic classes: strip the generic params.
// `array<K,V>` → `array`, `Collection<T>` → `Collection`
Expand Down Expand Up @@ -1781,6 +1812,7 @@ impl PhpType {
PhpType::Conditional { .. } => true,
PhpType::IntRange(..) => true,
PhpType::Literal(..) => true,
PhpType::StaticType(_) | PhpType::ThisType(_) => true,
PhpType::Raw(s) => s.contains('<') || s.contains('{') || s.ends_with("[]"),
}
}
Expand Down Expand Up @@ -1869,6 +1901,8 @@ impl PhpType {
| PhpType::InterfaceString(None)
| PhpType::IntRange(..)
| PhpType::Literal(..)
| PhpType::StaticType(_)
| PhpType::ThisType(_)
| PhpType::Raw(..) => false,
}
}
Expand Down
19 changes: 15 additions & 4 deletions src/php_type/subtype.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,18 @@ impl PhpType {
return is_named_subtype(sub, sup);
}

// ── StaticType / ThisType <: bound class ────────────────────
// StaticType(A) <: A and ThisType(A) <: A always hold.
// ThisType(A) <: StaticType(A) also holds ($this is more specific).
if let PhpType::StaticType(sub) | PhpType::ThisType(sub) = self {
match supertype {
PhpType::Named(sup) | PhpType::StaticType(sup) => {
return is_named_subtype(sub, sup);
}
_ => {}
}
}

// ── Literal subtyping ───────────────────────────────────────
if let PhpType::Literal(lit) = self {
return literal_is_subtype_of(lit, supertype);
Expand Down Expand Up @@ -384,10 +396,9 @@ impl PhpType {
/// used for the base name of `Generic` nodes where we have a
/// `&str` rather than a `&PhpType`.
pub(crate) fn is_self_ref_name(name: &str) -> bool {
matches!(
name.to_ascii_lowercase().as_str(),
"self" | "static" | "$this"
)
name.eq_ignore_ascii_case("self")
|| name.eq_ignore_ascii_case("static")
|| name.eq_ignore_ascii_case("$this")
}

// ---------------------------------------------------------------------------
Expand Down
Loading
Loading