From 4abfa75a13e06cb85d55cf8a436d4e8db4be6607 Mon Sep 17 00:00:00 2001 From: Caleb White Date: Sat, 25 Jul 2026 19:11:16 -0500 Subject: [PATCH] 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 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 --- docs/CHANGELOG.md | 2 +- src/code_actions/generate_constructor.rs | 7 +- src/diagnostics/type_errors/mod.rs | 30 +-- src/php_type/display.rs | 2 + src/php_type/mod.rs | 38 +++- src/php_type/subtype.rs | 19 +- src/php_type/transform.rs | 199 ++++++++++++++++-- src/php_type_tests.rs | 17 +- .../call_resolution/template_subs.rs | 6 +- src/type_engine/subject_resolution.rs | 10 +- src/type_engine/types/resolution.rs | 4 + .../variable/rhs_resolution/calls.rs | 8 +- .../variable/rhs_resolution/instantiation.rs | 6 +- src/types/resolved_type.rs | 2 +- src/virtual_members/phpdoc.rs | 2 +- tests/assert_type_runner.rs | 28 +-- tests/phpstan_nsrt/enums.php | 8 +- tests/phpstan_nsrt/methodPhpDocs.php | 8 +- tests/phpstan_nsrt/static-late-binding.php | 44 ++-- .../magic_method_annotation.php | 8 +- tests/psalm_assertions/method_call.php | 2 +- tests/psalm_assertions/mixin_annotation.php | 4 +- tests/psalm_assertions/return_type.php | 4 +- .../template_class_template.php | 4 +- 24 files changed, 362 insertions(+), 100 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 588366de4..613a11d1a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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` 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`. Contributed by @calebdw. -- **`class-string` parameters now diagnose provably invalid class strings.** Passing an unrelated class to a `class-string` 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` parameters now diagnose provably invalid class strings.** Passing an unrelated class to a `class-string` 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. diff --git a/src/code_actions/generate_constructor.rs b/src/code_actions/generate_constructor.rs index 40f7fc627..ff1509f61 100644 --- a/src/code_actions/generate_constructor.rs +++ b/src/code_actions/generate_constructor.rs @@ -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, } } diff --git a/src/diagnostics/type_errors/mod.rs b/src/diagnostics/type_errors/mod.rs index cfa4e87ff..831b97115 100644 --- a/src/diagnostics/type_errors/mod.rs +++ b/src/diagnostics/type_errors/mod.rs @@ -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()) } }) }; @@ -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 @@ -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 diff --git a/src/php_type/display.rs b/src/php_type/display.rs index efbbd18fe..5e5e4a4ff 100644 --- a/src/php_type/display.rs +++ b/src/php_type/display.rs @@ -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}"), diff --git a/src/php_type/mod.rs b/src/php_type/mod.rs index dc8d5edc1..ae48da35e 100644 --- a/src/php_type/mod.rs +++ b/src/php_type/mod.rs @@ -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), @@ -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())) } @@ -998,7 +1025,9 @@ impl PhpType { /// - `?T` → `?NativeT` pub fn to_native_hint(&self) -> Option { 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` → `array`, `Collection` → `Collection` @@ -1051,7 +1080,9 @@ impl PhpType { /// avoiding a parse round-trip. pub fn to_native_hint_typed(&self) -> Option { 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` → `array`, `Collection` → `Collection` @@ -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("[]"), } } @@ -1869,6 +1901,8 @@ impl PhpType { | PhpType::InterfaceString(None) | PhpType::IntRange(..) | PhpType::Literal(..) + | PhpType::StaticType(_) + | PhpType::ThisType(_) | PhpType::Raw(..) => false, } } diff --git a/src/php_type/subtype.rs b/src/php_type/subtype.rs index 92b82c347..2ff7c3ba3 100644 --- a/src/php_type/subtype.rs +++ b/src/php_type/subtype.rs @@ -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); @@ -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") } // --------------------------------------------------------------------------- diff --git a/src/php_type/transform.rs b/src/php_type/transform.rs index 2f882f941..8453faa06 100644 --- a/src/php_type/transform.rs +++ b/src/php_type/transform.rs @@ -140,6 +140,9 @@ impl PhpType { // Raw types can't be structurally resolved — pass through. PhpType::Raw(s) => PhpType::Raw(s.clone()), + + PhpType::StaticType(s) => PhpType::StaticType(atom(&resolver(s))), + PhpType::ThisType(s) => PhpType::ThisType(atom(&resolver(s))), } } @@ -252,6 +255,9 @@ impl PhpType { // for raw types that we couldn't parse structurally. PhpType::Raw(s.clone()) } + + PhpType::StaticType(s) => PhpType::StaticType(atom(Self::short_name_of(s))), + PhpType::ThisType(s) => PhpType::ThisType(atom(Self::short_name_of(s))), } } @@ -285,17 +291,155 @@ impl PhpType { /// /// [`resolve_names`]: PhpType::resolve_names pub fn resolve_self_refs(&self, class_name: &str, parent_class: Option<&str>) -> PhpType { - // self / static / $this — case-insensitive, whole-tree walk. - let replaced = self.replace_self(class_name); - match parent_class { - Some(parent) => { - let subs = std::collections::HashMap::from([( - "parent".to_string(), - PhpType::Named(atom(parent)), - )]); - replaced.substitute(&subs) - } - None => replaced, + self.resolve_self_refs_bounded(class_name, parent_class) + } + + /// Like [`resolve_self_refs`] but produces bounded static types: + /// `static` → [`StaticType(bound)`](PhpType::StaticType), + /// `$this` → [`ThisType(bound)`](PhpType::ThisType), + /// `self` → [`Named(class_name)`](PhpType::Named), + /// `parent` → [`Named(parent_class)`](PhpType::Named). + /// + /// Use this when the caller needs to preserve the late-static-binding + /// distinction rather than flattening everything to a concrete class. + pub fn resolve_self_refs_bounded( + &self, + class_name: &str, + parent_class: Option<&str>, + ) -> PhpType { + match self { + PhpType::Named(s) if is_self_ref_name(s) || s.eq_ignore_ascii_case("parent") => { + if s.eq_ignore_ascii_case("static") { + PhpType::StaticType(atom(class_name)) + } else if s.eq_ignore_ascii_case("$this") { + PhpType::ThisType(atom(class_name)) + } else if s.eq_ignore_ascii_case("parent") { + match parent_class { + Some(p) => PhpType::Named(atom(p)), + None => self.clone(), + } + } else { + PhpType::Named(atom(class_name)) + } + } + PhpType::Named(_) + | PhpType::StaticType(_) + | PhpType::ThisType(_) + | PhpType::Literal(_) + | PhpType::Raw(_) + | PhpType::IntRange(..) => self.clone(), + PhpType::Nullable(inner) => PhpType::Nullable(Box::new( + inner.resolve_self_refs_bounded(class_name, parent_class), + )), + PhpType::Union(types) => PhpType::Union( + types + .iter() + .map(|t| t.resolve_self_refs_bounded(class_name, parent_class)) + .collect(), + ), + PhpType::Intersection(types) => PhpType::Intersection( + types + .iter() + .map(|t| t.resolve_self_refs_bounded(class_name, parent_class)) + .collect(), + ), + PhpType::Generic(name, args) => { + let resolved_name = if is_self_ref_name(name) { + class_name.to_string() + } else if name.eq_ignore_ascii_case("parent") { + parent_class + .map(|p| p.to_string()) + .unwrap_or_else(|| name.clone()) + } else { + name.clone() + }; + PhpType::Generic( + resolved_name, + args.iter() + .map(|a| a.resolve_self_refs_bounded(class_name, parent_class)) + .collect(), + ) + } + PhpType::Array(inner) => PhpType::Array(Box::new( + inner.resolve_self_refs_bounded(class_name, parent_class), + )), + PhpType::ClassString(inner) => PhpType::ClassString( + inner + .as_ref() + .map(|t| Box::new(t.resolve_self_refs_bounded(class_name, parent_class))), + ), + PhpType::InterfaceString(inner) => PhpType::InterfaceString( + inner + .as_ref() + .map(|t| Box::new(t.resolve_self_refs_bounded(class_name, parent_class))), + ), + PhpType::ArrayShape(entries) => PhpType::ArrayShape( + entries + .iter() + .map(|e| super::ShapeEntry { + key: e.key.clone(), + value_type: e + .value_type + .resolve_self_refs_bounded(class_name, parent_class), + optional: e.optional, + }) + .collect(), + ), + PhpType::ObjectShape(entries) => PhpType::ObjectShape( + entries + .iter() + .map(|e| super::ShapeEntry { + key: e.key.clone(), + value_type: e + .value_type + .resolve_self_refs_bounded(class_name, parent_class), + optional: e.optional, + }) + .collect(), + ), + PhpType::Callable { + kind, + params, + return_type, + } => PhpType::Callable { + kind: kind.clone(), + params: params + .iter() + .map(|p| super::CallableParam { + type_hint: p + .type_hint + .resolve_self_refs_bounded(class_name, parent_class), + optional: p.optional, + variadic: p.variadic, + }) + .collect(), + return_type: return_type + .as_ref() + .map(|r| Box::new(r.resolve_self_refs_bounded(class_name, parent_class))), + }, + PhpType::Conditional { + param, + negated, + condition, + then_type, + else_type, + } => PhpType::Conditional { + param: param.clone(), + negated: *negated, + condition: Box::new(condition.resolve_self_refs_bounded(class_name, parent_class)), + then_type: Box::new(then_type.resolve_self_refs_bounded(class_name, parent_class)), + else_type: Box::new(else_type.resolve_self_refs_bounded(class_name, parent_class)), + }, + PhpType::KeyOf(inner) => PhpType::KeyOf(Box::new( + inner.resolve_self_refs_bounded(class_name, parent_class), + )), + PhpType::ValueOf(inner) => PhpType::ValueOf(Box::new( + inner.resolve_self_refs_bounded(class_name, parent_class), + )), + PhpType::IndexAccess(base, index) => PhpType::IndexAccess( + Box::new(base.resolve_self_refs_bounded(class_name, parent_class)), + Box::new(index.resolve_self_refs_bounded(class_name, parent_class)), + ), } } @@ -399,6 +543,7 @@ impl PhpType { PhpType::IndexAccess(base, index) => { base.contains_self_ref() || index.contains_self_ref() } + PhpType::StaticType(_) | PhpType::ThisType(_) => false, PhpType::Literal(_) | PhpType::Raw(_) | PhpType::IntRange(_, _) => false, } } @@ -421,12 +566,24 @@ impl PhpType { // Extract the base class name from the replacement for use in // Generic nodes where only the name part is replaced. let replacement_name = match replacement { - PhpType::Named(n) => n.as_str(), + PhpType::Named(n) | PhpType::StaticType(n) | PhpType::ThisType(n) => n.as_str(), PhpType::Generic(n, _) => n.as_str(), _ => "", }; match self { - PhpType::Named(_) if self.is_self_ref() => replacement.clone(), + PhpType::Named(s) if self.is_self_ref() => { + if let PhpType::Named(name) = replacement { + if s.eq_ignore_ascii_case("static") { + PhpType::StaticType(*name) + } else if s.eq_ignore_ascii_case("$this") { + PhpType::ThisType(*name) + } else { + replacement.clone() + } + } else { + replacement.clone() + } + } PhpType::Named(_) | PhpType::Literal(_) | PhpType::Raw(_) => self.clone(), @@ -547,6 +704,8 @@ impl PhpType { Box::new(base.replace_self_with_type(replacement)), Box::new(index.replace_self_with_type(replacement)), ), + + PhpType::StaticType(_) | PhpType::ThisType(_) => self.clone(), } } @@ -586,6 +745,8 @@ impl PhpType { PhpType::Literal(_) | PhpType::Raw(_) | PhpType::IntRange(_, _) => self.clone(), + PhpType::StaticType(_) | PhpType::ThisType(_) => self.clone(), + PhpType::Nullable(inner) => { let resolved = inner.substitute(subs); // If the substitution produced a union or nullable, @@ -869,6 +1030,12 @@ impl PhpType { else_type.collect_class_names(names); } + PhpType::StaticType(s) | PhpType::ThisType(s) => { + if !s.is_empty() && !names.iter().any(|n| n == s.as_str()) { + names.push(s.to_string()); + } + } + PhpType::Literal(_) | PhpType::Raw(_) | PhpType::IntRange(_, _) => {} } } @@ -904,6 +1071,12 @@ impl PhpType { names.push(name.clone()); } + PhpType::StaticType(s) | PhpType::ThisType(s) + if !s.is_empty() && !names.iter().any(|n| n == s.as_str()) => + { + names.push(s.to_string()); + } + // `User[]` — the inner type is the top-level class. PhpType::Array(inner) => inner.collect_top_level_class_names(names), diff --git a/src/php_type_tests.rs b/src/php_type_tests.rs index dea7089eb..e62bd6cb9 100644 --- a/src/php_type_tests.rs +++ b/src/php_type_tests.rs @@ -1676,13 +1676,16 @@ fn replace_self_named() { #[test] fn replace_self_static() { let ty = PhpType::parse("static"); - assert_eq!(ty.replace_self("App\\User").to_string(), "App\\User"); + assert_eq!( + ty.replace_self("App\\User").to_string(), + "static(App\\User)" + ); } #[test] fn replace_self_this() { let ty = PhpType::parse("$this"); - assert_eq!(ty.replace_self("App\\User").to_string(), "App\\User"); + assert_eq!(ty.replace_self("App\\User").to_string(), "$this(App\\User)"); } #[test] @@ -1696,7 +1699,7 @@ fn replace_self_in_union() { fn replace_self_in_generic() { let ty = PhpType::parse("Collection"); let replaced = ty.replace_self("App\\User"); - assert_eq!(replaced.to_string(), "Collection"); + assert_eq!(replaced.to_string(), "Collection"); } #[test] @@ -1752,7 +1755,13 @@ fn resolve_self_refs_in_array_element() { fn resolve_self_refs_static_in_generic() { let ty = PhpType::parse("array"); let resolved = ty.resolve_self_refs("App\\Cat", None); - assert_eq!(resolved, PhpType::parse("array")); + assert_eq!( + resolved, + PhpType::Generic( + "array".to_string(), + vec![PhpType::StaticType(atom("App\\Cat"))] + ) + ); } #[test] diff --git a/src/type_engine/call_resolution/template_subs.rs b/src/type_engine/call_resolution/template_subs.rs index a5ed8a2a2..f46274726 100644 --- a/src/type_engine/call_resolution/template_subs.rs +++ b/src/type_engine/call_resolution/template_subs.rs @@ -684,7 +684,11 @@ impl Backend { if is_self_or_static(trimmed) { return ctx.current_class.map(|c| { if ctx.preserve_static { - PhpType::Named(atom(trimmed)) + match trimmed { + "static" => PhpType::StaticType(c.fqn()), + "$this" => PhpType::ThisType(c.fqn()), + _ => PhpType::Named(c.fqn()), + } } else { PhpType::Named(atom(c.name.as_ref())) } diff --git a/src/type_engine/subject_resolution.rs b/src/type_engine/subject_resolution.rs index 3d00d06fe..260dfc9a0 100644 --- a/src/type_engine/subject_resolution.rs +++ b/src/type_engine/subject_resolution.rs @@ -45,10 +45,18 @@ pub(crate) fn resolve_subject_type( let trimmed = subject_text.trim(); match trimmed { - "$this" | "self" | "static" => { + "self" => { let fqn = find_enclosing_class_fqn(ctx.local_classes, ctx.namespace, access_offset)?; Some(PhpType::Named(atom(&fqn))) } + "static" => { + let fqn = find_enclosing_class_fqn(ctx.local_classes, ctx.namespace, access_offset)?; + Some(PhpType::StaticType(atom(&fqn))) + } + "$this" => { + let fqn = find_enclosing_class_fqn(ctx.local_classes, ctx.namespace, access_offset)?; + Some(PhpType::ThisType(atom(&fqn))) + } "parent" => { let cls = find_class_at_offset(ctx.local_classes, access_offset)?; let parent = cls.parent_class.as_ref()?; diff --git a/src/type_engine/types/resolution.rs b/src/type_engine/types/resolution.rs index c4bcf2695..5880e450e 100644 --- a/src/type_engine/types/resolution.rs +++ b/src/type_engine/types/resolution.rs @@ -170,6 +170,10 @@ fn type_hint_to_classes_typed_depth( })] } + PhpType::StaticType(s) | PhpType::ThisType(s) => { + resolve_named_type(s, &[], owning_class_name, all_classes, class_loader, depth) + } + // ── Named type (class name, keyword, or alias) ───────────── PhpType::Named(name) => resolve_named_type( name, diff --git a/src/type_engine/variable/rhs_resolution/calls.rs b/src/type_engine/variable/rhs_resolution/calls.rs index e874011d1..ee5ddee70 100644 --- a/src/type_engine/variable/rhs_resolution/calls.rs +++ b/src/type_engine/variable/rhs_resolution/calls.rs @@ -647,7 +647,9 @@ pub(super) fn resolve_rhs_function_call<'b>( if let Some(ref ret) = method_ret && ret.contains_self_ref() { - return vec![ResolvedType::from_type_string(PhpType::static_())]; + return vec![ResolvedType::from_type_string(PhpType::StaticType( + ctx.current_class.fqn(), + ))]; } } } @@ -700,7 +702,9 @@ pub(super) fn resolve_rhs_function_call<'b>( if let Some(ref ret) = method_ret && ret.contains_self_ref() { - return vec![ResolvedType::from_type_string(PhpType::static_())]; + return vec![ResolvedType::from_type_string(PhpType::ThisType( + ctx.current_class.fqn(), + ))]; } } } diff --git a/src/type_engine/variable/rhs_resolution/instantiation.rs b/src/type_engine/variable/rhs_resolution/instantiation.rs index 130f3a404..78bd53a66 100644 --- a/src/type_engine/variable/rhs_resolution/instantiation.rs +++ b/src/type_engine/variable/rhs_resolution/instantiation.rs @@ -37,7 +37,11 @@ pub(super) fn resolve_rhs_instantiation( ctx.class_loader, ), }; - let parsed_name = PhpType::Named(atom(&fqn)); + let parsed_name = if name == "static" { + PhpType::StaticType(atom(&fqn)) + } else { + PhpType::Named(atom(&fqn)) + }; let classes = crate::type_engine::type_resolution::type_hint_to_classes_typed( &parsed_name, &ctx.current_class.name, diff --git a/src/types/resolved_type.rs b/src/types/resolved_type.rs index ca1016dce..477f4c169 100644 --- a/src/types/resolved_type.rs +++ b/src/types/resolved_type.rs @@ -191,7 +191,7 @@ impl ResolvedType { .filter(|m| { // Keep members that were not resolved to a class. match m { - PhpType::Named(n) => { + PhpType::Named(n) | PhpType::StaticType(n) | PhpType::ThisType(n) => { let stripped = n.strip_prefix('\\').unwrap_or(n); !class_fqns.iter().any(|fqn| { fqn == stripped || crate::util::short_name(fqn) == stripped diff --git a/src/virtual_members/phpdoc.rs b/src/virtual_members/phpdoc.rs index a9d923530..c4b242625 100644 --- a/src/virtual_members/phpdoc.rs +++ b/src/virtual_members/phpdoc.rs @@ -1029,7 +1029,7 @@ fn return_type_is_mixin_self( || short_name(stripped) == mixin_short }; match ty { - PhpType::Named(n) => check_name(n), + PhpType::Named(n) | PhpType::StaticType(n) | PhpType::ThisType(n) => check_name(n), PhpType::Generic(n, _) => check_name(n), PhpType::Nullable(inner) => return_type_is_mixin_self( inner, diff --git a/tests/assert_type_runner.rs b/tests/assert_type_runner.rs index 2944c7946..68cc62650 100644 --- a/tests/assert_type_runner.rs +++ b/tests/assert_type_runner.rs @@ -742,19 +742,23 @@ fn types_match(expected: &str, actual: &str) -> bool { /// Shorten FQN components in a type string. /// `App\Models\User|null` → `User|null` +/// `static(App\Models\User)` → `static(User)` fn shorten_fqn_components(ty: &str) -> String { - ty.split('|') - .map(|part| { - let trimmed = part.trim(); - if trimmed.contains('\\') { - // Take the last segment. - trimmed.rsplit('\\').next().unwrap_or(trimmed) - } else { - trimmed - } - }) - .collect::>() - .join("|") + let mut result = String::new(); + let mut word = String::new(); + for ch in ty.chars() { + if ch == '\\' { + word.clear(); + } else if ch.is_alphanumeric() || ch == '_' || ch == '$' { + word.push(ch); + } else { + result.push_str(&word); + word.clear(); + result.push(ch); + } + } + result.push_str(&word); + result } /// Strip trailing `, mixed` params from Generator types so that diff --git a/tests/phpstan_nsrt/enums.php b/tests/phpstan_nsrt/enums.php index 7ab489a7d..1eccae884 100644 --- a/tests/phpstan_nsrt/enums.php +++ b/tests/phpstan_nsrt/enums.php @@ -50,8 +50,8 @@ public function doFoo(string $s, Bar $bar): void assertType('Bar', Bar::TWO); assertType('string', Bar::TWO->value); - assertType('Bar', Bar::from($s)); - assertType('Bar|null', Bar::tryFrom($s)); + assertType('static(EnumTypeAssertions\Bar)', Bar::from($s)); + assertType('?static(EnumTypeAssertions\Bar)', Bar::tryFrom($s)); assertType('string', $bar->value); } @@ -67,8 +67,8 @@ public function doFoo(int $i, Baz $baz): void assertType('Baz', Baz::TWO); assertType('int', Baz::TWO->value); - assertType('Baz', Baz::from($i)); - assertType('Baz|null', Baz::tryFrom($i)); + assertType('static(EnumTypeAssertions\Baz)', Baz::from($i)); + assertType('?static(EnumTypeAssertions\Baz)', Baz::tryFrom($i)); assertType('int', $baz->value); assertType('int', Baz::ONE->value); diff --git a/tests/phpstan_nsrt/methodPhpDocs.php b/tests/phpstan_nsrt/methodPhpDocs.php index 7173aba10..fa92b92c2 100644 --- a/tests/phpstan_nsrt/methodPhpDocs.php +++ b/tests/phpstan_nsrt/methodPhpDocs.php @@ -160,14 +160,14 @@ public function doFoo( assertType('MethodPhpDocsNamespace\Bar', static::doSomethingStatic()); // assertType('static(MethodPhpDocsNamespace\Foo)', parent::doLorem()); - assertType('MethodPhpDocsNamespace\FooParent', $parent->doLorem()); + assertType('static(MethodPhpDocsNamespace\FooParent)', $parent->doLorem()); // assertType('static(MethodPhpDocsNamespace\Foo)', $this->doLorem()); - assertType('MethodPhpDocsNamespace\Foo', $differentInstance->doLorem()); + assertType('static(MethodPhpDocsNamespace\Foo)', $differentInstance->doLorem()); // assertType('static(MethodPhpDocsNamespace\Foo)', parent::doIpsum()); - assertType('MethodPhpDocsNamespace\FooParent', $parent->doIpsum()); - assertType('MethodPhpDocsNamespace\Foo', $differentInstance->doIpsum()); + assertType('static(MethodPhpDocsNamespace\FooParent)', $parent->doIpsum()); + assertType('static(MethodPhpDocsNamespace\Foo)', $differentInstance->doIpsum()); // assertType('static(MethodPhpDocsNamespace\Foo)', $this->doIpsum()); assertType('MethodPhpDocsNamespace\Foo', $this->doBar()[0]); diff --git a/tests/phpstan_nsrt/static-late-binding.php b/tests/phpstan_nsrt/static-late-binding.php index 52f2b6d43..4397a9c2c 100644 --- a/tests/phpstan_nsrt/static-late-binding.php +++ b/tests/phpstan_nsrt/static-late-binding.php @@ -78,30 +78,30 @@ public function foo(): void assertType('bool', X::retStaticConst(...)()); assertType('mixed', $clUnioned->retStaticConst(...)()); - assertType('StaticLateBinding\A', A::retStatic()); - assertType('StaticLateBinding\B', B::retStatic()); - assertType('B', self::retStatic()); - assertType('B', static::retStatic()); - assertType('A', parent::retStatic()); - assertType('B', $this->retStatic()); + assertType('static(StaticLateBinding\A)', A::retStatic()); + assertType('static(StaticLateBinding\B)', B::retStatic()); + assertType('static(StaticLateBinding\B)', self::retStatic()); + assertType('static(StaticLateBinding\B)', static::retStatic()); + assertType('static(StaticLateBinding\A)', parent::retStatic()); + assertType('static(StaticLateBinding\B)', $this->retStatic()); assertType('bool', X::retStatic()); - assertType('bool|StaticLateBinding\A', $clUnioned::retStatic()); - - assertType('StaticLateBinding\A', A::retStatic(...)()); - assertType('StaticLateBinding\B', B::retStatic(...)()); - assertType('static', self::retStatic(...)()); - assertType('static', static::retStatic(...)()); - assertType('static', parent::retStatic(...)()); - assertType('static', $this->retStatic(...)()); + assertType('static(StaticLateBinding\A)|bool', $clUnioned::retStatic()); + + assertType('static(StaticLateBinding\A)', A::retStatic(...)()); + assertType('static(StaticLateBinding\B)', B::retStatic(...)()); + assertType('static(StaticLateBinding\B)', self::retStatic(...)()); + assertType('static(StaticLateBinding\B)', static::retStatic(...)()); + assertType('static(StaticLateBinding\B)', parent::retStatic(...)()); + assertType('$this(StaticLateBinding\B)', $this->retStatic(...)()); assertType('bool', X::retStatic(...)()); - assertType('StaticLateBinding\A|bool', $clUnioned::retStatic(...)()); - - assertType('A', A::retNonStatic()); - assertType('B', B::retNonStatic()); - assertType('B', self::retNonStatic()); - assertType('B', static::retNonStatic()); - assertType('A', parent::retNonStatic()); - assertType('B', $this->retNonStatic()); + assertType('static(StaticLateBinding\A)|bool', $clUnioned::retStatic(...)()); + + assertType('static(StaticLateBinding\A)', A::retNonStatic()); + assertType('static(StaticLateBinding\B)', B::retNonStatic()); + assertType('static(StaticLateBinding\B)', self::retNonStatic()); + assertType('static(StaticLateBinding\B)', static::retNonStatic()); + assertType('static(StaticLateBinding\A)', parent::retNonStatic()); + assertType('static(StaticLateBinding\B)', $this->retNonStatic()); assertType('bool', X::retNonStatic()); assertType('*ERROR*', $clUnioned->retNonStatic()); } diff --git a/tests/psalm_assertions/magic_method_annotation.php b/tests/psalm_assertions/magic_method_annotation.php index 73655408a..c7e217b4a 100644 --- a/tests/psalm_assertions/magic_method_annotation.php +++ b/tests/psalm_assertions/magic_method_annotation.php @@ -83,7 +83,7 @@ class Child extends ParentClass {} assertType('bool', $c); assertType('array', $d); assertType('callable(): string', $e); - assertType('Child', $f); + assertType('static(PsalmTest_magic_method_annotation_2\Child)', $f); } // Test: validStaticAnnotationWithDefault @@ -141,7 +141,7 @@ public function __call(string $c, array $args) {} $b = (new C)->getThis(); assertType('C', $a); - assertType('C', $b); + assertType('$this(PsalmTest_magic_method_annotation_5\C)', $b); } // Test: allowMagicMethodStatic @@ -156,8 +156,8 @@ class D extends C {} $c = (new C)->getStatic(); $d = (new D)->getStatic(); - assertType('C', $c); - assertType('D', $d); + assertType('static(PsalmTest_magic_method_annotation_6\C)', $c); + assertType('static(PsalmTest_magic_method_annotation_6\D)', $d); } // Test: validSimplePsalmAnnotations diff --git a/tests/psalm_assertions/method_call.php b/tests/psalm_assertions/method_call.php index 4273fc52f..d4dd1e503 100644 --- a/tests/psalm_assertions/method_call.php +++ b/tests/psalm_assertions/method_call.php @@ -12,7 +12,7 @@ final class MyDate extends DateTimeImmutable {} $b = (new DateTimeImmutable())->modify("+3 hours"); - assertType('MyDate', $yesterday); + assertType('static(PsalmTest_method_call_1\MyDate)', $yesterday); assertType('DateTimeImmutable|false', $b); } diff --git a/tests/psalm_assertions/mixin_annotation.php b/tests/psalm_assertions/mixin_annotation.php index c28147097..3d5262408 100644 --- a/tests/psalm_assertions/mixin_annotation.php +++ b/tests/psalm_assertions/mixin_annotation.php @@ -143,7 +143,7 @@ class FooModel extends Model {} $f = new FooModel(); $g = $f->getInner(); - assertType('list', $g); + assertType('list', $g); } // Test: mixinInheritMagicMethods @@ -165,6 +165,6 @@ public function __call(string $name, array $arguments) {} $b = new B; $c = $b->active(); - assertType('B', $c); + assertType('$this(PsalmTest_mixin_annotation_6\B)', $c); } diff --git a/tests/psalm_assertions/return_type.php b/tests/psalm_assertions/return_type.php index 2ce26d59c..4a47b6b70 100644 --- a/tests/psalm_assertions/return_type.php +++ b/tests/psalm_assertions/return_type.php @@ -20,7 +20,7 @@ class LoadChild extends BaseLoad { $b = LoadChild::load(); - assertType('LoadChild', $b); + assertType('static(PsalmTest_return_type_1\LoadChild)', $b); } // Test: extendsStaticCallArrayReturnType @@ -40,7 +40,7 @@ class MultiChild extends BaseMulti { $bees = MultiChild::loadMultiple(); - assertType('array', $bees); + assertType('array', $bees); } // Test: overrideReturnType diff --git a/tests/psalm_assertions/template_class_template.php b/tests/psalm_assertions/template_class_template.php index b82ae0f5d..7a2bf6ae4 100644 --- a/tests/psalm_assertions/template_class_template.php +++ b/tests/psalm_assertions/template_class_template.php @@ -188,10 +188,10 @@ class G extends E {} assertType('Foo', $efoo); assertType('Foo', $efoo2); - assertType('Foo', $efoo3); + assertType('Foo', $efoo3); assertType('Foo', $gfoo); assertType('Foo', $gfoo2); - assertType('Foo', $gfoo3); + assertType('Foo', $gfoo3); } // Test: classTemplateExternalClasses