Skip to content

Commit caa6163

Browse files
committed
Fix class-string<T> static method dispatch and return type resolution
1 parent 4329efe commit caa6163

9 files changed

Lines changed: 526 additions & 34 deletions

File tree

docs/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6161

6262
### Fixed
6363

64+
- **`class-string<T>` static method dispatch.** When a variable is typed as `class-string<Foo>` (via `@param` or `@template` bound), calling static methods on it (e.g. `$class::from()`, `$class::cases()`) now resolves the return type correctly. The `static` return type substitutes to the bound class, so `$class::from('x')->name` resolves and `foreach ($class::cases() as $item)` gives `$item` the correct type.
65+
- **`@var` docblock annotations no longer leak across class and method boundaries.** When the cursor was inside a nested block (foreach, if, while), a `@var` annotation for a same-named variable in a completely different class could bleed into the current scope, producing wrong type information. The backward docblock scanner now correctly detects sibling scopes regardless of nesting depth.
66+
6467
- **PHPStan `*` wildcard in generic type arguments.** Type strings like `Relation<TRelatedModel, *, *>` now parse correctly instead of falling back to an unparsed blob. Previously, `mago-type-syntax` rejected the `*` token, causing the entire type to be treated as a single unknown class name (e.g. `Class 'Relation<TRelatedModel, *, *>|string' not found`). The `*` wildcard is now replaced with `mixed` before parsing, matching PHPStan's semantics. Member references like `Foo::*` and constant patterns like `int-mask-of<self::FOO_*>` are not affected.
6568
- **Closure parameter inference from function-level `@template` bindings.** Functions like `array_any`, `array_all`, and `array_find` that declare `@template` parameters bound through an array parameter and used in a `callable` parameter now infer concrete types for untyped closure arguments. For example, `array_any($this->items, fn($item) => $item->name !== '')` where `$this->items` is `array<int, Product>` now resolves `$item` as `Product`. Previously `$item` was unresolved, producing false-positive `unresolved_member_access` diagnostics. This also fixes a related issue where `@param` tags in phpstorm-stubs that omit the parameter name (e.g. `@param callable(TValue, TKey): bool` without `$callback`) failed to enrich the parameter's type hint, preventing callable parameter type extraction.
6669
- **Property chain arguments in template substitution.** Expressions like `$this->items` or `$obj->prop` passed as arguments to templated functions now resolve their type for template binding. Previously only bare variables (e.g. `$items`) were resolved, so `array_any($this->items, fn($x) => ...)` could not infer the element type even when the property had a fully substituted generic type.

docs/todo.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ within the same impact tier.
2323

2424
| # | Item | Impact | Effort |
2525
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | ----------- |
26-
| T23 | [`class-string<T>` static method dispatch](todo/type-inference.md#t23-class-stringt-static-method-dispatch) | Medium | Medium |
26+
2727
| H10 | [`return.unusedType` — remove unused type from return union](todo/phpstan-actions.md#h10-returnunusedtype--remove-unused-type-from-return-union) | Medium | Medium |
2828
| H6 | `return.type` — update return type to match actual returns | Medium | Medium |
2929
| | **Release 0.7.0** | | |

docs/todo/type-inference.md

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -488,30 +488,7 @@ simpler, but basic reconciliation can work with strings too).
488488

489489
---
490490

491-
## T23. `class-string<T>` static method dispatch
492-
**Impact: Medium · Effort: Medium**
493491

494-
When a variable is typed as `class-string<Foo>` (via `@param` or
495-
native type), calling static methods on it (e.g. `$class::cases()`,
496-
`$class::create()`) should resolve through `Foo` as the base class.
497-
PHPantom currently does not resolve static method calls on
498-
`class-string`-typed variables, so the return type is unknown and
499-
any chaining breaks.
500-
501-
A specific sub-problem is the `static` return type: `UnitEnum::cases()`
502-
returns `static[]`. When called via `$class::cases()` where `$class`
503-
is `class-string<BackedEnum>`, the return type should resolve as
504-
`BackedEnum[]`, making `$item->name` and `$item->value` accessible
505-
in a `foreach`.
506-
507-
Example from triage: `OptionList:27,30``$class::cases()` where
508-
`$class` is typed as `class-string<BackedEnum>`. Properties `name`
509-
and `value` on the iterated elements are unresolved.
510-
511-
**Implementation:** in the static call resolution path, detect when
512-
the subject is a variable with a `class-string<T>` type. Extract `T`
513-
and resolve the method on that class. When the return type contains
514-
`static`, substitute it with `T`.
515492

516493
---
517494

example.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -854,6 +854,29 @@ public function demo(): void
854854
}
855855

856856

857+
// ── Class-String Parameter Static Dispatch ──────────────────────────────────
858+
859+
class ClassStringParamDispatchDemo
860+
{
861+
/**
862+
* @param class-string<\BackedEnum> $enumClass
863+
*/
864+
public function demo(string $enumClass): void
865+
{
866+
// Static method dispatch through class-string<T> parameter.
867+
// $enumClass::from() returns static, resolved to BackedEnum.
868+
$result = $enumClass::from('foo');
869+
$result->name; // property from UnitEnum via BackedEnum
870+
871+
// Foreach over $enumClass::cases() resolves items to BackedEnum.
872+
foreach ($enumClass::cases() as $item) {
873+
$item->value; // property from BackedEnum
874+
$item->name; // property from UnitEnum
875+
}
876+
}
877+
}
878+
879+
857880
// ── Ambiguous Variables ─────────────────────────────────────────────────────
858881

859882
class AmbiguousVariableDemo

src/completion/variable/resolution.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,7 @@ fn resolve_variable_in_members<'b>(
901901
// cases where the class declares `map(object $entity)`
902902
// but the interface has `@param TEntity $entity` with
903903
// `@implements Interface<Boo>` substituting `TEntity`.
904+
let mut merged_type_hint: Option<String> = None;
904905
let method_name = method.name.value.to_string();
905906
let merged = crate::virtual_members::resolve_class_fully_maybe_cached(
906907
ctx.current_class,
@@ -933,6 +934,12 @@ fn resolve_variable_in_members<'b>(
933934
);
934935
break;
935936
}
937+
// The merged type hint is richer than the
938+
// native hint (e.g. `list<Pen>` vs `array`)
939+
// but didn't resolve to a class. Remember
940+
// it so the type-string-only fallback below
941+
// uses it instead of the bare native hint.
942+
merged_type_hint = Some(hint_str);
936943
}
937944
}
938945

@@ -944,7 +951,14 @@ fn resolve_variable_in_members<'b>(
944951
// Prefer the docblock type (e.g. `class-string<BackedEnum>`)
945952
// over the native type (e.g. `string`) when the
946953
// docblock provides a more specific annotation.
947-
let best_type_str = raw_docblock_type.as_deref().or(type_str_for_resolution);
954+
// When the merged class provides a richer type from
955+
// parent/interface inheritance (e.g. `list<Pen>` from
956+
// a parent's `@param`), prefer that over the bare
957+
// native hint.
958+
let best_type_str = raw_docblock_type
959+
.as_deref()
960+
.or(merged_type_hint.as_deref())
961+
.or(type_str_for_resolution);
948962
if let Some(ts) = best_type_str {
949963
let mut parsed = PhpType::parse(ts);
950964

src/completion/variable/rhs_resolution.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1805,7 +1805,39 @@ fn resolve_rhs_static_call(
18051805
ctx.cursor_offset,
18061806
ctx.class_loader,
18071807
);
1808-
targets.first().map(|c| c.name.clone())
1808+
if let Some(first) = targets.first() {
1809+
Some(first.name.clone())
1810+
} else {
1811+
// Fallback: resolve the variable's type and extract the
1812+
// inner type from `class-string<T>`. This handles
1813+
// parameters typed as `@param class-string<Foo> $var`
1814+
// where there is no `$var = Foo::class` assignment.
1815+
let resolved = super::resolution::resolve_variable_types(
1816+
&var_name,
1817+
ctx.current_class,
1818+
ctx.all_classes,
1819+
ctx.content,
1820+
ctx.cursor_offset,
1821+
ctx.class_loader,
1822+
Loaders::with_function(ctx.function_loader()),
1823+
);
1824+
resolved.iter().find_map(|rt| match &rt.type_string {
1825+
PhpType::ClassString(Some(inner)) => Some(inner.to_string()),
1826+
PhpType::Nullable(inner) => match inner.as_ref() {
1827+
PhpType::ClassString(Some(cs_inner)) => Some(cs_inner.to_string()),
1828+
_ => None,
1829+
},
1830+
PhpType::Union(members) => members.iter().find_map(|m| match m {
1831+
PhpType::ClassString(Some(inner)) => Some(inner.to_string()),
1832+
PhpType::Nullable(inner) => match inner.as_ref() {
1833+
PhpType::ClassString(Some(cs_inner)) => Some(cs_inner.to_string()),
1834+
_ => None,
1835+
},
1836+
_ => None,
1837+
}),
1838+
_ => None,
1839+
})
1840+
}
18091841
}
18101842
_ => None,
18111843
};

src/docblock/tags.rs

Lines changed: 207 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -514,10 +514,14 @@ pub fn find_var_raw_type_in_source(
514514
min_depth = min_depth.min(brace_depth);
515515

516516
// Once we have exited our containing scope (min_depth < 0) and
517-
// re-entered a block at depth >= 0, we are inside a sibling
518-
// scope (e.g. a different method in the same class). From that
519-
// point on every annotation belongs to a foreign scope.
520-
if min_depth < 0 && brace_depth >= 0 {
517+
// re-entered a block close to that level, we are inside a
518+
// sibling scope (e.g. a different method in the same class).
519+
// From that point on every annotation belongs to a foreign
520+
// scope. The threshold is `min_depth + 1` rather than `>= 0`
521+
// because the cursor may be inside a nested block (foreach,
522+
// if, etc.) whose extra depth prevents brace_depth from ever
523+
// reaching 0 when traversing sibling classes.
524+
if min_depth < 0 && brace_depth > min_depth {
521525
seen_sibling_scope = true;
522526
}
523527
if seen_sibling_scope {
@@ -966,10 +970,19 @@ pub fn find_iterable_raw_type_in_source(
966970
min_depth = min_depth.min(brace_depth);
967971

968972
// Once we have exited our containing scope (min_depth < 0) and
969-
// re-entered a block at depth >= 0, we are inside a sibling
970-
// scope (e.g. a different method in the same class). From that
971-
// point on every annotation belongs to a foreign scope.
972-
if min_depth < 0 && brace_depth >= 0 {
973+
// re-entered a block close to that level, we are inside a
974+
// sibling scope (e.g. a different method in the same class).
975+
// From that point on every annotation belongs to a foreign
976+
// scope.
977+
//
978+
// The threshold is `min_depth + 1` rather than `>= 0` because
979+
// the cursor may be inside a nested block (foreach, if, etc.)
980+
// that adds extra depth. When starting inside a foreach in a
981+
// class method, min_depth reaches -3 (foreach { + method { +
982+
// class {), so a sibling method body at depth -1 would never
983+
// reach 0. Using `min_depth + 1` catches the first rise back
984+
// toward our exit point.
985+
if min_depth < 0 && brace_depth > min_depth {
973986
seen_sibling_scope = true;
974987
}
975988
if seen_sibling_scope {
@@ -1890,4 +1903,190 @@ mod tests {
18901903
let doc = "/**\n * @deprecated Use foo() instead.\n * @see NewClass\n * @return int\n */";
18911904
assert_eq!(extract_removed_version(doc), None);
18921905
}
1906+
1907+
// ── find_var_raw_type_in_source — scope isolation ───────────────
1908+
1909+
#[test]
1910+
fn var_docblock_does_not_leak_across_sibling_methods() {
1911+
// A `@var` in one class method must not be visible in another.
1912+
let src = concat!(
1913+
"<?php\n",
1914+
"class A {\n",
1915+
" public function first(): void {\n",
1916+
" /** @var object{title: string} $item */\n",
1917+
" $item = foo();\n",
1918+
" }\n",
1919+
"}\n",
1920+
"class B {\n",
1921+
" public function second(): void {\n",
1922+
" $item->\n", // cursor here
1923+
" }\n",
1924+
"}\n",
1925+
);
1926+
let cursor = src.find("$item->").unwrap();
1927+
let result = find_var_raw_type_in_source(src, cursor, "$item");
1928+
assert_eq!(
1929+
result, None,
1930+
"@var from A::first() must not leak into B::second()"
1931+
);
1932+
}
1933+
1934+
#[test]
1935+
fn var_docblock_does_not_leak_across_sibling_methods_same_class() {
1936+
let src = concat!(
1937+
"<?php\n",
1938+
"class Demo {\n",
1939+
" public function first(): void {\n",
1940+
" /** @var Pen $item */\n",
1941+
" $item = foo();\n",
1942+
" }\n",
1943+
" public function second(): void {\n",
1944+
" $item->\n",
1945+
" }\n",
1946+
"}\n",
1947+
);
1948+
let cursor = src.find("$item->").unwrap();
1949+
let result = find_var_raw_type_in_source(src, cursor, "$item");
1950+
assert_eq!(
1951+
result, None,
1952+
"@var from first() must not leak into second()"
1953+
);
1954+
}
1955+
1956+
#[test]
1957+
fn var_docblock_does_not_leak_when_cursor_inside_nested_block() {
1958+
// The original bug: when the cursor is inside a foreach (or if,
1959+
// while, etc.), the extra nesting depth prevented the sibling
1960+
// scope detection from firing, allowing @var from a method in a
1961+
// completely different class to leak through.
1962+
let src = concat!(
1963+
"<?php\n",
1964+
"class ObjectShapeDemo {\n",
1965+
" public function demo(): void {\n",
1966+
" /** @var object{title: string, score: float} $item */\n",
1967+
" $item = getUnknownValue();\n",
1968+
" }\n",
1969+
"}\n",
1970+
"class Other {\n",
1971+
" public function demo(): void {\n",
1972+
" foreach ($things as $item) {\n",
1973+
" $item->\n",
1974+
" }\n",
1975+
" }\n",
1976+
"}\n",
1977+
);
1978+
let cursor = src.find("$item->").unwrap();
1979+
let result = find_var_raw_type_in_source(src, cursor, "$item");
1980+
assert_eq!(
1981+
result, None,
1982+
"@var from ObjectShapeDemo must not leak into Other when cursor is inside foreach"
1983+
);
1984+
}
1985+
1986+
#[test]
1987+
fn var_docblock_found_in_own_scope() {
1988+
let src = concat!(
1989+
"<?php\n",
1990+
"class Demo {\n",
1991+
" public function demo(): void {\n",
1992+
" /** @var Pen $item */\n",
1993+
" $item = foo();\n",
1994+
" $item->\n",
1995+
" }\n",
1996+
"}\n",
1997+
);
1998+
let cursor = src.find("$item->").unwrap();
1999+
let result = find_var_raw_type_in_source(src, cursor, "$item");
2000+
assert_eq!(result, Some("Pen".to_string()));
2001+
}
2002+
2003+
#[test]
2004+
fn var_docblock_found_inside_nested_block_in_own_scope() {
2005+
let src = concat!(
2006+
"<?php\n",
2007+
"class Demo {\n",
2008+
" public function demo(): void {\n",
2009+
" /** @var Pen $item */\n",
2010+
" $item = foo();\n",
2011+
" if (true) {\n",
2012+
" $item->\n",
2013+
" }\n",
2014+
" }\n",
2015+
"}\n",
2016+
);
2017+
let cursor = src.find("$item->").unwrap();
2018+
let result = find_var_raw_type_in_source(src, cursor, "$item");
2019+
assert_eq!(result, Some("Pen".to_string()));
2020+
}
2021+
2022+
// ── find_iterable_raw_type_in_source — scope isolation ──────────
2023+
2024+
#[test]
2025+
fn iterable_docblock_does_not_leak_across_sibling_classes_nested() {
2026+
// Same bug scenario for find_iterable_raw_type_in_source:
2027+
// @var in a sibling class method leaks when cursor is nested.
2028+
let src = concat!(
2029+
"<?php\n",
2030+
"class A {\n",
2031+
" public function demo(): void {\n",
2032+
" /** @var object{title: string} $item */\n",
2033+
" $item = foo();\n",
2034+
" }\n",
2035+
"}\n",
2036+
"class B {\n",
2037+
" public function demo(): void {\n",
2038+
" foreach ($things as $x) {\n",
2039+
" $item->\n",
2040+
" }\n",
2041+
" }\n",
2042+
"}\n",
2043+
);
2044+
let cursor = src.find("$item->").unwrap();
2045+
let result = find_iterable_raw_type_in_source(src, cursor, "$item");
2046+
assert_eq!(
2047+
result, None,
2048+
"@var from A::demo() must not leak into B::demo() foreach body"
2049+
);
2050+
}
2051+
2052+
#[test]
2053+
fn iterable_param_found_in_own_method_from_nested_block() {
2054+
// @param in the enclosing method's docblock must still be found
2055+
// even when the cursor is inside a nested block.
2056+
let src = concat!(
2057+
"<?php\n",
2058+
"class Demo {\n",
2059+
" /**\n",
2060+
" * @param list<Pen> $items\n",
2061+
" */\n",
2062+
" public function demo(array $items): void {\n",
2063+
" foreach ($items as $x) {\n",
2064+
" // cursor\n",
2065+
" }\n",
2066+
" }\n",
2067+
"}\n",
2068+
);
2069+
let cursor = src.find("// cursor").unwrap();
2070+
let result = find_iterable_raw_type_in_source(src, cursor, "$items");
2071+
assert_eq!(result, Some("list<Pen>".to_string()));
2072+
}
2073+
2074+
#[test]
2075+
fn iterable_var_found_in_own_scope_nested() {
2076+
let src = concat!(
2077+
"<?php\n",
2078+
"class Demo {\n",
2079+
" public function demo(): void {\n",
2080+
" /** @var list<Pen> $items */\n",
2081+
" $items = foo();\n",
2082+
" foreach ($items as $x) {\n",
2083+
" // cursor\n",
2084+
" }\n",
2085+
" }\n",
2086+
"}\n",
2087+
);
2088+
let cursor = src.find("// cursor").unwrap();
2089+
let result = find_iterable_raw_type_in_source(src, cursor, "$items");
2090+
assert_eq!(result, Some("list<Pen>".to_string()));
2091+
}
18932092
}

tests/fixture_runner.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ static UNIT_ENUM_STUB: &str = "\
2222
<?php
2323
interface UnitEnum
2424
{
25+
/** @return static[] */
2526
public static function cases(): array;
2627
public readonly string $name;
2728
}

0 commit comments

Comments
 (0)