Skip to content

Commit 7230d35

Browse files
committed
Fix in_array guard clause type narrowing and class-string<T> static
return
1 parent 9ede72e commit 7230d35

5 files changed

Lines changed: 186 additions & 34 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6767

6868
### Fixed
6969

70+
- **`in_array` guard clause no longer wipes out variable type.** When `in_array($item, $exclude, true)` was used as a guard clause (`if (...) { continue; }`) and the haystack's element type matched the variable's resolved type (e.g. both `BackedEnum`), the narrowing system incorrectly excluded the type entirely, leaving the variable untyped for all subsequent accesses. The `in_array` check filters by value, not by type, so the exclusion now skips when it would remove all type information.
7071
- **`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.
7172
- **`@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.
7273

docs/todo.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ within the same impact tier.
2424
| # | Item | Impact | Effort |
2525
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------- |
2626
| B10 | [`instanceof` ternary narrowing fails when target class is in a phar](todo/bugs.md#b10-instanceof-ternary-narrowing-fails-when-target-class-is-in-a-phar) | Low | Low-Medium |
27-
| B11 | [`static` return type not resolved through `class-string<T>` context](todo/bugs.md#b11-static-return-type-not-resolved-through-class-stringt-context) | Low | Medium |
2827
| B12 | [`Collection::reduce()` generic return type not inferred](todo/bugs.md#b12-collectionreduce-generic-return-type-not-inferred) | Low | Medium |
2928
| B13 | [Array shape tracking from keyed literal assignments in loops](todo/bugs.md#b13-array-shape-tracking-from-keyed-literal-assignments-in-loops) | Low | High |
3029
| H10 | [`return.unusedType` — remove unused type from return union](todo/phpstan-actions.md#h10-returnunusedtype--remove-unused-type-from-return-union) | Medium | Medium |

docs/todo/bugs.md

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -35,39 +35,6 @@ narrowing to occur).
3535

3636
---
3737

38-
## B11. `static` return type not resolved through `class-string<T>` context
39-
**Impact: Low · Effort: Medium**
40-
41-
Pattern:
42-
```php
43-
/** @param class-string<BackedEnum> $class */
44-
function enum(BackedEnum $value, string $class): void {
45-
foreach ($class::cases() as $item) {
46-
$name = $item->name; // unresolved
47-
$val = $item->value; // unresolved
48-
}
49-
}
50-
```
51-
52-
`UnitEnum::cases()` returns `static[]`. When called on a variable
53-
typed as `class-string<BackedEnum>`, the `static` return type should
54-
resolve to `BackedEnum[]`, making `$item` typed as `BackedEnum`.
55-
Currently `static` is not substituted through the `class-string<T>`
56-
context, so `$item` has no type and `->name` / `->value` are
57-
unresolvable.
58-
59-
**Observed in:** `OptionList:27,30``$class::cases()` where
60-
`$class` is `class-string<BackedEnum>`.
61-
62-
**Root cause:** The `class-string<T>` static dispatch fix (`caa6163`)
63-
resolves method signatures and return types for static calls on
64-
`class-string`-typed variables, but does not substitute `static` in
65-
the return type with the bound type `T`. The substitution
66-
`static → BackedEnum` needs to happen when the call target is
67-
resolved through a `class-string<BackedEnum>` context.
68-
69-
---
70-
7138
## B12. `Collection::reduce()` generic return type not inferred
7239
**Impact: Low · Effort: Medium**
7340

src/completion/types/narrowing.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2161,6 +2161,9 @@ pub(in crate::completion) fn try_apply_in_array_narrowing(
21612161
&& let Some(element_type) = resolve_in_array_element_type(haystack_expr, ctx)
21622162
{
21632163
if negated {
2164+
if !results.is_empty() && would_exclude_all_results(&element_type, results, ctx) {
2165+
return;
2166+
}
21642167
apply_instanceof_exclusion(&element_type, ctx, results);
21652168
} else {
21662169
apply_instanceof_inclusion(&element_type, false, ctx, results);
@@ -2191,11 +2194,44 @@ pub(in crate::completion) fn try_apply_in_array_narrowing_inverse(
21912194
if negated {
21922195
apply_instanceof_inclusion(&element_type, false, ctx, results);
21932196
} else {
2197+
if !results.is_empty() && would_exclude_all_results(&element_type, results, ctx) {
2198+
return;
2199+
}
21942200
apply_instanceof_exclusion(&element_type, ctx, results);
21952201
}
21962202
}
21972203
}
21982204

2205+
/// Check whether excluding `element_type` from `results` would remove
2206+
/// every entry, leaving the variable completely untyped.
2207+
///
2208+
/// `in_array($var, $haystack)` checks whether a *value* is present in
2209+
/// the array, not whether the *type* matches. When the haystack's
2210+
/// element type is the same as (or a supertype of) every entry in
2211+
/// `results`, exclusion would incorrectly wipe out all type information.
2212+
/// For example, `in_array($item, $exclude)` where both `$item` and
2213+
/// `$exclude`'s elements are `BackedEnum` should not remove
2214+
/// `BackedEnum` from the resolved types — the variable is still a
2215+
/// `BackedEnum`, just not one of the excluded values.
2216+
fn would_exclude_all_results(
2217+
element_type: &str,
2218+
results: &[ClassInfo],
2219+
ctx: &VarResolutionCtx<'_>,
2220+
) -> bool {
2221+
let excluded = super::resolution::type_hint_to_classes(
2222+
element_type,
2223+
&ctx.current_class.name,
2224+
ctx.all_classes,
2225+
ctx.class_loader,
2226+
);
2227+
if excluded.is_empty() {
2228+
return false;
2229+
}
2230+
results
2231+
.iter()
2232+
.all(|r| excluded.iter().any(|e| e.name == r.name))
2233+
}
2234+
21992235
/// Apply `in_array` guard clause narrowing after an `if` statement
22002236
/// whose then-body unconditionally exits.
22012237
///
@@ -2227,6 +2263,15 @@ pub(in crate::completion) fn apply_guard_clause_in_array_narrowing(
22272263
if condition_negated {
22282264
apply_instanceof_inclusion(&element_type, false, ctx, results);
22292265
} else {
2266+
// Skip exclusion when it would remove ALL type information.
2267+
// `in_array($item, $exclude)` where both `$item` and the
2268+
// elements of `$exclude` are the same type (e.g. both are
2269+
// `BackedEnum`) only narrows which *value* of that type the
2270+
// variable holds, not the type itself. Excluding would
2271+
// incorrectly wipe out the variable's type entirely.
2272+
if !results.is_empty() && would_exclude_all_results(&element_type, results, ctx) {
2273+
return;
2274+
}
22302275
apply_instanceof_exclusion(&element_type, ctx, results);
22312276
}
22322277
}

src/diagnostics/unknown_members.rs

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5345,4 +5345,144 @@ function test(object $data): void {
53455345
"expected no diagnostics for object-typed parameter, got: {diags:?}"
53465346
);
53475347
}
5348+
5349+
// ── class-string<T> static return type resolution ───────────────
5350+
5351+
#[test]
5352+
fn no_diagnostic_for_class_string_static_return_in_foreach() {
5353+
// When a parameter is typed `class-string<BackedEnum>` and we
5354+
// call `$class::cases()`, the `static[]` return type should
5355+
// resolve to `BackedEnum[]`, making foreach items typed as
5356+
// `BackedEnum` with `->name` and `->value` available.
5357+
// UnitEnum and BackedEnum are loaded from stubs (cross-file),
5358+
// not defined inline, to reproduce the real-world scenario.
5359+
// Uses the exact pattern from OptionList.php including the
5360+
// ternary with dynamic method call.
5361+
let php = r#"<?php
5362+
class OptionList {
5363+
/**
5364+
* @param class-string<BackedEnum> $class
5365+
*/
5366+
public static function enum(BackedEnum $value, string $class, array $exclude = [], string $method = ''): void {
5367+
foreach ($class::cases() as $item) {
5368+
if (in_array($item, $exclude, true)) {
5369+
continue;
5370+
}
5371+
5372+
$name = $method ? $item->{$method}() : $item->name;
5373+
5374+
$val = $item->value;
5375+
}
5376+
}
5377+
}
5378+
"#;
5379+
let backend = create_enum_backend();
5380+
backend.config.lock().diagnostics.unresolved_member_access = Some(true);
5381+
let diags = collect(&backend, "file:///test.php", php);
5382+
assert!(
5383+
diags.is_empty(),
5384+
"expected no diagnostics for class-string<BackedEnum> foreach item members, got: {diags:?}"
5385+
);
5386+
}
5387+
5388+
#[test]
5389+
fn no_diagnostic_for_class_string_static_return_chained() {
5390+
// `$class::from('foo')` returns `static` which should resolve
5391+
// to `BackedEnum` when `$class` is `class-string<BackedEnum>`.
5392+
// Members like `->name` should be available on the result.
5393+
// UnitEnum and BackedEnum are loaded from stubs (cross-file),
5394+
// not defined inline, to reproduce the real-world scenario.
5395+
let php = r#"<?php
5396+
class Svc {
5397+
/**
5398+
* @param class-string<BackedEnum> $class
5399+
*/
5400+
public function resolve(string $class): void {
5401+
$result = $class::from('foo');
5402+
$name = $result->name;
5403+
$val = $result->value;
5404+
}
5405+
}
5406+
"#;
5407+
let backend = create_enum_backend();
5408+
backend.config.lock().diagnostics.unresolved_member_access = Some(true);
5409+
let diags = collect(&backend, "file:///test.php", php);
5410+
assert!(
5411+
diags.is_empty(),
5412+
"expected no diagnostics for class-string<BackedEnum> static return chain, got: {diags:?}"
5413+
);
5414+
}
5415+
5416+
#[test]
5417+
fn in_array_guard_does_not_wipe_type_when_element_matches() {
5418+
// When `in_array($item, $exclude, true)` is used as a guard
5419+
// clause (`if (...) { continue; }`), the `in_array` narrowing
5420+
// should NOT exclude the variable's type when the haystack's
5421+
// element type matches the variable's type. The check filters
5422+
// by value, not by type — `$item` is still a `BackedEnum`
5423+
// after the guard, just not one of the excluded values.
5424+
let php = r#"<?php
5425+
class Foo {
5426+
public string $name;
5427+
}
5428+
5429+
class Svc {
5430+
/**
5431+
* @param array<int, Foo> $exclude
5432+
*/
5433+
public function run(Foo $item, array $exclude): void {
5434+
if (in_array($item, $exclude, true)) {
5435+
return;
5436+
}
5437+
$name = $item->name;
5438+
}
5439+
}
5440+
"#;
5441+
let backend = Backend::new_test();
5442+
backend.config.lock().diagnostics.unresolved_member_access = Some(true);
5443+
let diags = collect(&backend, "file:///test.php", php);
5444+
assert!(
5445+
diags.is_empty(),
5446+
"in_array guard should not wipe variable type when element type matches, got: {diags:?}"
5447+
);
5448+
}
5449+
5450+
#[test]
5451+
fn in_array_guard_still_narrows_union_type() {
5452+
// When the variable is a union type (e.g. `Foo|Bar`) and the
5453+
// haystack element type is one of the union members (e.g.
5454+
// `array<int, Foo>`), the guard clause SHOULD narrow: after
5455+
// `if (in_array($item, $fooList)) { return; }`, `$item` is
5456+
// not `Foo`, so it must be `Bar`. The would-exclude-all
5457+
// check should NOT prevent this narrowing because removing
5458+
// `Foo` still leaves `Bar`.
5459+
let php = r#"<?php
5460+
class Foo {
5461+
public string $fooName;
5462+
}
5463+
class Bar {
5464+
public string $barName;
5465+
}
5466+
5467+
class Svc {
5468+
/**
5469+
* @param Foo|Bar $item
5470+
* @param array<int, Foo> $fooList
5471+
*/
5472+
public function run(object $item, array $fooList): void {
5473+
if (in_array($item, $fooList, true)) {
5474+
return;
5475+
}
5476+
$name = $item->barName;
5477+
}
5478+
}
5479+
"#;
5480+
let backend = Backend::new_test();
5481+
backend.config.lock().diagnostics.unresolved_member_access = Some(true);
5482+
let diags = collect(&backend, "file:///test.php", php);
5483+
assert!(
5484+
diags.is_empty(),
5485+
"in_array guard should still narrow union types, got: {diags:?}"
5486+
);
5487+
}
53485488
}

0 commit comments

Comments
 (0)