Skip to content

Commit 03ceb68

Browse files
committed
refactor: model static and $this as bounded types in the type system
Add PhpType::StaticType(Atom) and PhpType::ThisType(Atom) variants that preserve late-static-binding semantics instead of flattening static/$this to a bare class name. StaticType carries the bound class ("at least this class or a subclass"), ThisType is more specific ("the exact runtime instance type"). The subtype chain is ThisType(A) <: StaticType(A) <: Named(A). Display: static(Foo), $this(Foo) -- shows the bound class. Production sites updated: - replace_self(fqn) now delegates to resolve_self_refs_bounded() so static -> StaticType(fqn) and $this -> ThisType(fqn); replace_self_with_type(&receiver) is unchanged (preserves full generic receiver types for accurate chain resolution) - Subject resolution: $this -> ThisType, static -> StaticType - First-class callable partial application: preserves static/this - Template substitution: preserve_static path uses bounded types - new static() -> StaticType instead of Named - Diagnostic param checking: resolve_self_refs_bounded() produces bounded types so class-string<static> is properly validated Subtype checking updated: - StaticType(A) <: Named(A) and ThisType(A) <: Named(A) - ThisType(A) <: StaticType(A) - base_name(), top_level_class_names(), collect_class_names() all handle the new variants Peripheral sites updated: - return_type_is_mixin_self handles StaticType/ThisType - is_simple_php_type handles StaticType/ThisType - Union member deduplication handles StaticType/ThisType
1 parent 138bcf3 commit 03ceb68

24 files changed

Lines changed: 362 additions & 100 deletions

docs/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6464
- **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.
6565
- **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.
6666
- **`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.
67-
- **`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.
67+
- **`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.
6868
- **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.
6969
- **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.
7070
- **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.

src/code_actions/generate_constructor.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -303,8 +303,11 @@ fn collect_qualifying_properties<'a>(
303303
/// intersections, array shapes, generics, etc.
304304
fn is_simple_php_type(ty: &PhpType) -> bool {
305305
match ty {
306-
PhpType::Named(_) => true,
307-
PhpType::Nullable(inner) => matches!(inner.as_ref(), PhpType::Named(_)),
306+
PhpType::Named(_) | PhpType::StaticType(_) | PhpType::ThisType(_) => true,
307+
PhpType::Nullable(inner) => matches!(
308+
inner.as_ref(),
309+
PhpType::Named(_) | PhpType::StaticType(_) | PhpType::ThisType(_)
310+
),
308311
_ => false,
309312
}
310313
}

src/diagnostics/type_errors/mod.rs

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -558,17 +558,17 @@ impl Backend {
558558
None
559559
};
560560
class_part.and_then(|cp| {
561-
let low = cp.to_ascii_lowercase();
562-
match low.as_str() {
563-
"self" | "static" | "$this" => {
564-
find_innermost_enclosing_class(&file_ctx.classes, call_site.args_start)
565-
.map(|c| c.fqn().to_string())
566-
}
567-
"parent" => {
568-
find_innermost_enclosing_class(&file_ctx.classes, call_site.args_start)
569-
.and_then(|c| c.parent_class.as_ref().map(|p| p.to_string()))
570-
}
571-
_ => class_loader(cp).map(|cls| cls.fqn().to_string()),
561+
if cp.eq_ignore_ascii_case("self")
562+
|| cp.eq_ignore_ascii_case("static")
563+
|| cp.eq_ignore_ascii_case("$this")
564+
{
565+
find_innermost_enclosing_class(&file_ctx.classes, call_site.args_start)
566+
.map(|c| c.fqn().to_string())
567+
} else if cp.eq_ignore_ascii_case("parent") {
568+
find_innermost_enclosing_class(&file_ctx.classes, call_site.args_start)
569+
.and_then(|c| c.parent_class.as_ref().map(|p| p.to_string()))
570+
} else {
571+
class_loader(cp).map(|cls| cls.fqn().to_string())
572572
}
573573
})
574574
};
@@ -637,7 +637,7 @@ impl Backend {
637637
let effective_param_type = if param_type.contains_self_ref() {
638638
if let Some(ref fqn) = call_context_class {
639639
resolved_param =
640-
param_type.resolve_self_refs(fqn, ctx_parent_fqn.as_deref());
640+
param_type.resolve_self_refs_bounded(fqn, ctx_parent_fqn.as_deref());
641641
&resolved_param
642642
} else {
643643
param_type
@@ -702,8 +702,10 @@ impl Backend {
702702
let resolved_alt;
703703
let effective_alt = if alt_type.contains_self_ref() {
704704
if let Some(ref fqn) = call_context_class {
705-
resolved_alt = alt_type
706-
.resolve_self_refs(fqn, ctx_parent_fqn.as_deref());
705+
resolved_alt = alt_type.resolve_self_refs_bounded(
706+
fqn,
707+
ctx_parent_fqn.as_deref(),
708+
);
707709
&resolved_alt
708710
} else {
709711
alt_type

src/php_type/display.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ impl fmt::Display for PhpType {
1010
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1111
match self {
1212
PhpType::Named(s) => write!(f, "{s}"),
13+
PhpType::StaticType(bound) => write!(f, "static({bound})"),
14+
PhpType::ThisType(bound) => write!(f, "$this({bound})"),
1315

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

src/php_type/mod.rs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,30 @@ pub enum PhpType {
5555
/// never fed unbounded free text.
5656
Named(Atom),
5757

58+
/// Late-static-binding type with a known lower bound.
59+
///
60+
/// Represents `static` or `$this` resolved in the context of a class:
61+
/// "the runtime class, which is at least `bound`." Unlike
62+
/// [`Named`](PhpType::Named) with the class FQN, this preserves the
63+
/// polymorphic semantics of `static` — a `StaticType("Base")` is a
64+
/// subtype of `Base` but is not the same as `Named("Base")` because it
65+
/// could be any subclass at runtime.
66+
///
67+
/// Produced when `Named("static")` is resolved in a class context.
68+
/// `self` is NOT represented here — it resolves to
69+
/// `Named(declaring_class)` since it is invariant.
70+
StaticType(Atom),
71+
72+
/// The `$this` type with a known lower bound.
73+
///
74+
/// More specific than [`StaticType`](PhpType::StaticType): represents
75+
/// the exact runtime instance, not just "this class or a subclass."
76+
/// `ThisType("Foo") <: StaticType("Foo") <: Named("Foo")`.
77+
///
78+
/// Important for fluent interfaces (`@return $this`) where the return
79+
/// type must preserve the receiver's exact type through method chains.
80+
ThisType(Atom),
81+
5882
/// Nullable type: `?T`.
5983
Nullable(Box<PhpType>),
6084

@@ -971,6 +995,9 @@ impl PhpType {
971995
PhpType::Named(s) if !is_scalar_name(s) => {
972996
Some(s.strip_prefix('\\').unwrap_or(s.as_str()))
973997
}
998+
PhpType::StaticType(s) | PhpType::ThisType(s) => {
999+
Some(s.strip_prefix('\\').unwrap_or(s.as_str()))
1000+
}
9741001
PhpType::Generic(name, _) if !is_scalar_name(name) => {
9751002
Some(name.strip_prefix('\\').unwrap_or(name.as_str()))
9761003
}
@@ -998,7 +1025,9 @@ impl PhpType {
9981025
/// - `?T` → `?NativeT`
9991026
pub fn to_native_hint(&self) -> Option<String> {
10001027
match self {
1001-
PhpType::Named(s) => native_scalar_name(s).map(|n| n.to_string()),
1028+
PhpType::Named(s) | PhpType::StaticType(s) | PhpType::ThisType(s) => {
1029+
native_scalar_name(s).map(|n| n.to_string())
1030+
}
10021031
PhpType::Generic(name, _) => {
10031032
// Generic classes: strip the generic params.
10041033
// `array<K,V>` → `array`, `Collection<T>` → `Collection`
@@ -1051,7 +1080,9 @@ impl PhpType {
10511080
/// avoiding a parse round-trip.
10521081
pub fn to_native_hint_typed(&self) -> Option<PhpType> {
10531082
match self {
1054-
PhpType::Named(s) => native_scalar_name(s).map(|n| PhpType::Named(atom(n))),
1083+
PhpType::Named(s) | PhpType::StaticType(s) | PhpType::ThisType(s) => {
1084+
native_scalar_name(s).map(|n| PhpType::Named(atom(n)))
1085+
}
10551086
PhpType::Generic(name, _) => {
10561087
// Generic classes: strip the generic params.
10571088
// `array<K,V>` → `array`, `Collection<T>` → `Collection`
@@ -1781,6 +1812,7 @@ impl PhpType {
17811812
PhpType::Conditional { .. } => true,
17821813
PhpType::IntRange(..) => true,
17831814
PhpType::Literal(..) => true,
1815+
PhpType::StaticType(_) | PhpType::ThisType(_) => true,
17841816
PhpType::Raw(s) => s.contains('<') || s.contains('{') || s.ends_with("[]"),
17851817
}
17861818
}
@@ -1869,6 +1901,8 @@ impl PhpType {
18691901
| PhpType::InterfaceString(None)
18701902
| PhpType::IntRange(..)
18711903
| PhpType::Literal(..)
1904+
| PhpType::StaticType(_)
1905+
| PhpType::ThisType(_)
18721906
| PhpType::Raw(..) => false,
18731907
}
18741908
}

src/php_type/subtype.rs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,18 @@ impl PhpType {
140140
return is_named_subtype(sub, sup);
141141
}
142142

143+
// ── StaticType / ThisType <: bound class ────────────────────
144+
// StaticType(A) <: A and ThisType(A) <: A always hold.
145+
// ThisType(A) <: StaticType(A) also holds ($this is more specific).
146+
if let PhpType::StaticType(sub) | PhpType::ThisType(sub) = self {
147+
match supertype {
148+
PhpType::Named(sup) | PhpType::StaticType(sup) => {
149+
return is_named_subtype(sub, sup);
150+
}
151+
_ => {}
152+
}
153+
}
154+
143155
// ── Literal subtyping ───────────────────────────────────────
144156
if let PhpType::Literal(lit) = self {
145157
return literal_is_subtype_of(lit, supertype);
@@ -384,10 +396,9 @@ impl PhpType {
384396
/// used for the base name of `Generic` nodes where we have a
385397
/// `&str` rather than a `&PhpType`.
386398
pub(crate) fn is_self_ref_name(name: &str) -> bool {
387-
matches!(
388-
name.to_ascii_lowercase().as_str(),
389-
"self" | "static" | "$this"
390-
)
399+
name.eq_ignore_ascii_case("self")
400+
|| name.eq_ignore_ascii_case("static")
401+
|| name.eq_ignore_ascii_case("$this")
391402
}
392403

393404
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)