Skip to content

Commit c541bac

Browse files
committed
Suppress diagnostics for stdClass and object property access
1 parent cefa772 commit c541bac

7 files changed

Lines changed: 211 additions & 20 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7575
- **Hover on the `$` sign of a variable at its assignment site showed no type.** Hovering on the `$` of `$order = new Order()` displayed a bare `$order` with no type information, while hovering one character to the right on `o` worked correctly. The variable's byte offset coincided with the assignment statement's start offset, causing the resolver to skip the assignment entirely. The cursor offset is now nudged past the statement boundary so the assignment is included.
7676
- **Method chains through `__call` no longer lose the return type.** When a class defines `__call` and an unrecognized method is called (e.g. dynamic `whereColumn()` on an Eloquent Builder), the return type of `__call` is now used as a fallback. If `__call` returns `$this`, `static`, or `self`, the chain type is preserved so subsequent calls continue resolving. Previously the type was lost at the first dynamic call, breaking every link after it. Eloquent Builder's `__call` is patched from `mixed` to `static` during resolution, matching its runtime behavior (scope dispatch, macro dispatch, and Query\Builder forwarding all return the Builder instance).
7777
- **Callable parameter inference now preserves generic arguments from the receiver.** When a closure parameter is typed as a bare supertype (e.g. `fn(Builder $q)`) but the enclosing method's callable signature provides a more specific type via `$this` or `static` (e.g. `callable($this)`), the inferred type now carries the receiver's generic arguments. For example, calling `->when($flag, fn(Builder $q) => $q->active())` on a `Builder<Product>` chain now infers `$q` as `Builder<Product>`, so model-specific scope methods resolve correctly instead of being flagged as unknown.
78+
- **`stdClass` and `object` types no longer produce false-positive diagnostics.** Variables typed as `object` (via parameter type, `@var`, or type-guard narrowing) and `stdClass` now permit arbitrary property access without `unresolved_member_access` diagnostics. Type-guard functions like `is_object()` now narrow `mixed` to `object` instead of filtering it to empty. Compound `&&` conditions propagate type-guard narrowing to both the condition's right-hand operands and the if-body, so `if (is_object($data) && $data->name)` resolves correctly throughout.
7879
- **Null narrowing from `!== null` checks in conditions.** When a null-initialized variable was guarded by `$var !== null` in an `if` or `while` condition, the variable still showed `null` in its type inside the condition's `&&` operands and inside the then-body. The `!== null` check (and `!is_null()`, bare truthy guards) now narrows away `null` both for subsequent `&&` operands and inside the corresponding body block. Chained conditions like `$a !== null && $b !== null && $a->method()` narrow all checked variables. Conditions wrapped inside ternary expressions and return statements are also handled.
7980
- **Variables assigned inside `if`/`while` conditions now resolve in the body.** `if ($admin = AdminUser::first())` and `while ($row = nextRow())` now register the assignment so the variable has a type inside the loop or branch body. Assignments wrapped in comparisons like `if (($conn = getConn()) !== null)` are also recognized.
8081
- **Fluent chains only flag the first broken link.** In a chain like `$m->callHome()->callMom()->callDad()` where `callHome` does not exist, only `callHome` is flagged. Previously every subsequent link received its own "cannot verify" warning, burying the root cause in noise. Separate statements on the same variable (`$m->callHome(); $m->callMom();`) still flag independently. Scalar member access chains (`$user->getAge()->value->deep`) flag only the first scalar break. Null-safe, static, and mixed-operator chains are all handled.

docs/todo.md

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

2424
| # | Item | Impact | Effort |
2525
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | ----------- |
26-
| T24 | [`stdClass` dynamic property access](todo/type-inference.md#t24-stdclass-dynamic-property-access) (suppress diagnostics on `stdClass`/`object`) | Low-Medium | Low |
2726
| T22 | [Array value type tracking from loop assignments](todo/type-inference.md#t22-array-value-type-tracking-from-loop-assignments) | Medium | Medium |
2827
| T23 | [`class-string<T>` static method dispatch](todo/type-inference.md#t23-class-stringt-static-method-dispatch) | Medium | Medium |
2928
| T21 | [Bidirectional template inference](todo/type-inference.md#t21-bidirectional-template-inference-upperlower-bounds) (closure return type → generic) | Medium | Medium-High |

docs/todo/type-inference.md

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -589,24 +589,27 @@ via `is_object()`) should not produce `unresolved_member_access`
589589
diagnostics, because `stdClass` permits arbitrary properties by
590590
design.
591591

592-
This affects two common patterns:
593-
594-
1. `json_decode($json, false)` returns `mixed`. After an
595-
`is_object($data)` guard, `$data` should narrow to `object`, and
596-
property access like `$data->error_link` should be permitted
597-
without a diagnostic. (`Order:646,647`.)
598-
2. `DB::select()` returns `array`. After `$result[0] instanceof
599-
stdClass`, the element should narrow to `stdClass` and property
600-
access should be permitted. (`PurchaseFileService:1081,1083`.)
601-
602-
Both patterns also depend on narrowing improvements tracked in T20
603-
(`is_object()` narrowing, `instanceof` on array element access).
604-
This item covers the additional step: once the type is narrowed to
605-
`stdClass` or `object`, suppress member-not-found diagnostics.
606-
607-
**Implementation:** in the diagnostic emission path for
608-
`unknown_member` and `unresolved_member_access`, skip the diagnostic
609-
when the resolved type is `stdClass` or bare `object`. Optionally,
610-
resolve any property access on `stdClass` as `mixed`.
592+
**Partially resolved.** Three changes landed:
593+
594+
1. `filter_type_by_guard` now narrows `mixed` → the canonical type
595+
for each guard kind (e.g. `is_object()``object`) instead of
596+
filtering `mixed` to empty.
597+
2. `resolve_subject_outcome_variable` returns a synthetic
598+
`Resolved(stdClass)` when the resolved type is `object` or
599+
`stdClass`, so the existing `check_member_on_resolved_classes`
600+
suppression kicks in.
601+
3. `try_apply_type_guard_narrowing` decomposes compound `&&`
602+
conditions so `if (is_object($x) && $x->prop)` narrows in both
603+
the condition RHS and the if-body. `apply_and_lhs_narrowing` also
604+
handles `is_object()` in `&&` inline narrowing.
605+
606+
This fixed `Order:646,647` (`json_decode``mixed``is_object`
607+
guard → property access).
608+
609+
**Remaining:** `PurchaseFileService:1081,1083` — `$result[0]
610+
instanceof stdClass` where `$result` is bare `array` from
611+
`DB::select()`. This requires `instanceof` narrowing on array
612+
element access expressions, which is a T20 concern (the narrowing
613+
system only matches bare variable names, not `$arr[0]`).
611614

612615
---

example.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,20 @@ public function demo(null|array|Pen $input): void
253253
if (is_object($mixed)) {
254254
$mixed->weigh(); // both Rock and Banana have weigh()
255255
}
256+
257+
// is_object() narrows mixed → object, suppressing diagnostics
258+
// on dynamic property access (stdClass / object permit any property).
259+
$decoded = json_decode('{}'); // mixed
260+
if (is_object($decoded)) {
261+
echo $decoded->anything; // no diagnostic — object allows any property
262+
}
263+
264+
// Compound && condition: is_object() narrowing propagates
265+
// through the entire condition and into the if-body.
266+
$payload = json_decode('{}'); // mixed
267+
if (is_object($payload) && property_exists($payload, 'name')) {
268+
echo $payload->name; // no diagnostic
269+
}
256270
}
257271
}
258272

src/completion/resolver.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,6 +1063,24 @@ fn resolve_subject_outcome_variable(var_name: &str, ctx: &ResolutionCtx<'_>) ->
10631063
.map_or_else(|| joined.to_string(), |t| t.to_string());
10641064
return SubjectOutcome::Scalar(display);
10651065
}
1066+
1067+
// ── stdClass / object — synthetic resolution ────────────────
1068+
// `stdClass` and `object` are not loadable classes but they
1069+
// permit arbitrary property access. Return a synthetic
1070+
// `Resolved(stdClass)` so the downstream member check
1071+
// (which already suppresses diagnostics for stdClass) kicks
1072+
// in instead of falling to `Untyped`.
1073+
if resolved.iter().any(|rt| {
1074+
matches!(&rt.type_string,
1075+
PhpType::Named(s) if s.eq_ignore_ascii_case("stdclass") || s == "object")
1076+
}) {
1077+
let synthetic = Arc::new(ClassInfo {
1078+
name: "stdClass".to_string(),
1079+
..ClassInfo::default()
1080+
});
1081+
return SubjectOutcome::Resolved(vec![synthetic]);
1082+
}
1083+
10661084
// The resolved types contain non-scalar, non-class entries
10671085
// (e.g. type aliases we can't resolve). Check for
10681086
// unresolvable class names.

src/completion/types/narrowing.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1492,6 +1492,23 @@ fn apply_and_lhs_narrowing(
14921492
results,
14931493
);
14941494
}
1495+
return;
1496+
}
1497+
1498+
// Try type-guard functions (`is_object`, `is_array`, etc.).
1499+
// When `is_object($var)` appears in the LHS of `&&` and
1500+
// the variable has no resolved type yet, inject a synthetic
1501+
// `stdClass` so that downstream member-access diagnostics
1502+
// are suppressed (stdClass permits arbitrary properties).
1503+
if let Some((kind, negated)) = try_extract_type_guard(expr, ctx.var_name)
1504+
&& !negated
1505+
&& kind == TypeGuardKind::Object
1506+
&& results.is_empty()
1507+
{
1508+
results.push(ClassInfo {
1509+
name: "stdClass".to_string(),
1510+
..ClassInfo::default()
1511+
});
14951512
}
14961513
}
14971514
}
@@ -2503,6 +2520,25 @@ enum TypeGuardKind {
25032520
Callable,
25042521
}
25052522

2523+
/// Return the canonical `PhpType` that a type-guard narrows `mixed` to.
2524+
///
2525+
/// When a variable has type `mixed` and a type-guard like `is_object()`
2526+
/// succeeds, the variable should narrow to `object` (not stay `mixed`
2527+
/// and not become empty). This function maps each guard kind to the
2528+
/// PHP type it asserts.
2529+
fn guard_kind_to_narrowed_type(kind: TypeGuardKind) -> PhpType {
2530+
match kind {
2531+
TypeGuardKind::Array => PhpType::Named("array".to_string()),
2532+
TypeGuardKind::String => PhpType::Named("string".to_string()),
2533+
TypeGuardKind::Int => PhpType::Named("int".to_string()),
2534+
TypeGuardKind::Float => PhpType::Named("float".to_string()),
2535+
TypeGuardKind::Bool => PhpType::Named("bool".to_string()),
2536+
TypeGuardKind::Object => PhpType::Named("object".to_string()),
2537+
TypeGuardKind::Numeric => PhpType::Named("numeric".to_string()),
2538+
TypeGuardKind::Callable => PhpType::Named("callable".to_string()),
2539+
}
2540+
}
2541+
25062542
/// Try to extract a type-guard function call on a variable.
25072543
///
25082544
/// Matches `is_array($var)`, `is_string($var)`, etc. (with optional
@@ -2730,6 +2766,21 @@ fn filter_type_by_guard(ty: &PhpType, kind: TypeGuardKind, keep_matching: bool)
27302766
}
27312767
}
27322768
other => {
2769+
// `mixed` includes all types. When narrowing in the
2770+
// then-body (`keep_matching = true`), replace `mixed`
2771+
// with the canonical type for the guard kind (e.g.
2772+
// `is_object($mixed)` → `object`). In the else-body
2773+
// (`keep_matching = false`), `mixed` minus one kind is
2774+
// still effectively `mixed`, so leave it unchanged.
2775+
if let PhpType::Named(name) = other
2776+
&& name.eq_ignore_ascii_case("mixed")
2777+
{
2778+
return if keep_matching {
2779+
Some(guard_kind_to_narrowed_type(kind))
2780+
} else {
2781+
None // mixed minus one kind ≈ mixed
2782+
};
2783+
}
27332784
// Non-union type: if it matches the predicate, keep it.
27342785
if type_matches_guard(other, kind) == keep_matching {
27352786
None // no change needed
@@ -2755,6 +2806,21 @@ pub(in crate::completion) fn try_apply_type_guard_narrowing(
27552806
if ctx.cursor_offset < body_span.start.offset || ctx.cursor_offset > body_span.end.offset {
27562807
return;
27572808
}
2809+
2810+
// ── Compound `&&` / `and` ───────────────────────────────────────
2811+
// `if (is_object($x) && is_string($y))` — both sides are true
2812+
// inside the then-body. Decompose and apply each guard found.
2813+
if let Expression::Binary(bin) = condition
2814+
&& matches!(
2815+
bin.operator,
2816+
BinaryOperator::And(_) | BinaryOperator::LowAnd(_)
2817+
)
2818+
{
2819+
try_apply_type_guard_narrowing(bin.lhs, body_span, ctx, results);
2820+
try_apply_type_guard_narrowing(bin.rhs, body_span, ctx, results);
2821+
return;
2822+
}
2823+
27582824
if let Some((kind, negated)) = try_extract_type_guard(condition, ctx.var_name) {
27592825
if negated {
27602826
apply_type_guard_exclusion(kind, results);

src/diagnostics/unknown_members.rs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5255,4 +5255,94 @@ class OrderService {
52555255
"expected no diagnostics for variable reassigned inside nested foreach loops, got: {diags:?}"
52565256
);
52575257
}
5258+
5259+
#[test]
5260+
fn no_diagnostic_for_object_parameter_type() {
5261+
let php = r#"<?php
5262+
function test(object $obj): void {
5263+
echo $obj->anything;
5264+
$obj->whatever();
5265+
}
5266+
"#;
5267+
let backend = Backend::new_test();
5268+
let diags = collect(&backend, "file:///test.php", php);
5269+
assert!(
5270+
diags.is_empty(),
5271+
"expected no diagnostics for object parameter type, got: {diags:?}"
5272+
);
5273+
}
5274+
5275+
#[test]
5276+
fn no_diagnostic_after_is_object_guard() {
5277+
let php = r#"<?php
5278+
function test(mixed $data): void {
5279+
if (is_object($data)) {
5280+
echo $data->error_link;
5281+
}
5282+
}
5283+
"#;
5284+
let backend = Backend::new_test();
5285+
backend.config.lock().diagnostics.unresolved_member_access = Some(true);
5286+
let diags = collect(&backend, "file:///test.php", php);
5287+
assert!(
5288+
diags.is_empty(),
5289+
"expected no diagnostics after is_object() guard, got: {diags:?}"
5290+
);
5291+
}
5292+
5293+
#[test]
5294+
fn no_diagnostic_after_is_object_guard_with_negated_early_return() {
5295+
let php = r#"<?php
5296+
function test(mixed $data): void {
5297+
if (!is_object($data)) {
5298+
return;
5299+
}
5300+
echo $data->error_link;
5301+
echo $data->something_else;
5302+
$data->doStuff();
5303+
}
5304+
"#;
5305+
let backend = Backend::new_test();
5306+
backend.config.lock().diagnostics.unresolved_member_access = Some(true);
5307+
let diags = collect(&backend, "file:///test.php", php);
5308+
assert!(
5309+
diags.is_empty(),
5310+
"expected no diagnostics after negated is_object() early return, got: {diags:?}"
5311+
);
5312+
}
5313+
5314+
#[test]
5315+
fn no_diagnostic_after_is_object_in_compound_and_condition() {
5316+
let php = r#"<?php
5317+
function test(mixed $data): void {
5318+
if (is_object($data) && property_exists($data, 'error_link') && is_string($data->error_link)) {
5319+
echo stripslashes($data->error_link);
5320+
}
5321+
}
5322+
"#;
5323+
let backend = Backend::new_test();
5324+
backend.config.lock().diagnostics.unresolved_member_access = Some(true);
5325+
let diags = collect(&backend, "file:///test.php", php);
5326+
assert!(
5327+
diags.is_empty(),
5328+
"expected no diagnostics after is_object() in compound && condition, got: {diags:?}"
5329+
);
5330+
}
5331+
5332+
#[test]
5333+
fn no_diagnostic_for_object_typed_parameter() {
5334+
let php = r#"<?php
5335+
function test(object $data): void {
5336+
echo $data->name;
5337+
$data->doStuff();
5338+
}
5339+
"#;
5340+
let backend = Backend::new_test();
5341+
backend.config.lock().diagnostics.unresolved_member_access = Some(true);
5342+
let diags = collect(&backend, "file:///test.php", php);
5343+
assert!(
5344+
diags.is_empty(),
5345+
"expected no diagnostics for object-typed parameter, got: {diags:?}"
5346+
);
5347+
}
52585348
}

0 commit comments

Comments
 (0)