Skip to content

Commit eafb028

Browse files
committed
Track variable reassignment inside try/catch/finally blocks
1 parent 1ec7675 commit eafb028

5 files changed

Lines changed: 170 additions & 33 deletions

File tree

docs/CHANGELOG.md

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

5757
### Fixed
5858

59+
- **Variable reassignment inside `try` blocks now tracked by the analyzer.** When a variable was reassigned to a different type inside a `try` block, subsequent accesses within the same block still resolved against the original type, producing false-positive `unknown_member` diagnostics. The same applied to `catch` and `finally` blocks. Assignments preceding the cursor within the same block are now treated as sequential.
5960
- **Diagnostics now work for vendor files open in the editor.** Projects using `--prefer-source` or monorepo setups where libraries are edited from the `vendor/` directory no longer have diagnostics suppressed. Previously all vendor files were unconditionally skipped, hiding syntax errors, deprecation warnings, and all other diagnostics. The `analyze` command also accepts vendor paths as explicit overrides (e.g. `phpantom_lsp analyze vendor/some-package/src/`).
6061
- **Full-line diagnostic suppression no longer requires a code mapping.** Full-line diagnostics (from tools like PHPStan that only report line numbers) are now suppressed whenever any precise diagnostic exists on the same line, regardless of error codes. Previously suppression relied on a hand-maintained mapping between error codes from different tools, which was incomplete and allowed redundant full-line underlines to obscure the precise location of the issue.
6162

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-
| B3 | [Variable reassignment inside `try` block not tracked by analyzer](todo/bugs.md#b3-variable-reassignment-inside-try-block-not-tracked-by-analyzer) | Low | Low |
2726
| B4 | [Relationship property access and BelongsTo return type not resolved](todo/bugs.md#b4-relationship-property-access-and-belongsto-return-type-not-resolved-by-analyzer) | Medium | Medium |
2827
| B5 | [`$this->items` on custom Collection subclass not typed](todo/bugs.md#b5-thisitems-on-custom-collection-subclass-not-typed) | Low | Medium |
2928
| B6 | [Scope methods not found on Builder in analyzer chains](todo/bugs.md#b6-scope-methods-not-found-on-builder-in-analyzer-chains) | High | Medium |

docs/todo/bugs.md

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,5 @@
11
# PHPantom — Bug Fixes
22

3-
## B3: Variable reassignment inside `try` block not tracked by analyzer
4-
5-
When a variable is assigned one type before a `try` block and then
6-
reassigned to a different type inside the `try`, the analyzer uses the
7-
original type instead of the reassigned type for subsequent accesses
8-
within the same block.
9-
10-
Real-world example — `MollieGateway.php`:
11-
12-
```php
13-
$customer = $request->getCustomerOrFail(); // type: Luxplus Customer
14-
// ...
15-
try {
16-
$customer = $client->getOrCreateCustoemr($customer); // reassigned to MollieCustomer
17-
$molliePayment = $customer->createPayment($paymentData); // ← analyzer uses original type
18-
}
19-
```
20-
21-
Line 452 reassigns `$customer` from `Luxplus\...\Customer` to
22-
`Mollie\Api\Resources\Customer` (aliased as `MollieCustomer`). Line 453
23-
calls `->createPayment()` which exists on `MollieCustomer` but not on
24-
the Luxplus `Customer` model. The analyzer resolves `$customer` as the
25-
original type, producing an `unknown_member` diagnostic for
26-
`createPayment` and a cascading `unresolved_member_access` for
27-
`$molliePayment->getCheckoutUrl()`.
28-
29-
**Impact:** 2 diagnostics in the shared project (`MollieGateway:453`,
30-
`MollieGateway:462`).
31-
323
## B4: Relationship property access and BelongsTo return type not resolved by analyzer
334

345
Eloquent relationship methods accessed as properties (without `()`) are

src/completion/variable/resolution.rs

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2509,7 +2509,22 @@ fn walk_try_statement<'b>(
25092509
ctx: &VarResolutionCtx<'_>,
25102510
results: &mut Vec<ResolvedType>,
25112511
) {
2512-
walk_statements_for_assignments(try_stmt.block.statements.iter(), ctx, results, true);
2512+
// When the cursor is inside the try block body, assignments that
2513+
// precede the cursor have executed sequentially — use
2514+
// `conditional: false` so reassignments replace the previous type
2515+
// instead of forming a union with it. When the cursor is outside
2516+
// (after the try statement), we don't know whether the try body
2517+
// ran to completion, so keep `conditional: true`.
2518+
let try_span = try_stmt.block.span();
2519+
let cursor_in_try =
2520+
ctx.cursor_offset >= try_span.start.offset && ctx.cursor_offset <= try_span.end.offset;
2521+
walk_statements_for_assignments(
2522+
try_stmt.block.statements.iter(),
2523+
ctx,
2524+
results,
2525+
!cursor_in_try,
2526+
);
2527+
25132528
for catch in try_stmt.catch_clauses.iter() {
25142529
// Seed the catch variable's type from the catch
25152530
// clause's type hint(s) before recursing into the
@@ -2528,10 +2543,28 @@ fn walk_try_statement<'b>(
25282543
);
25292544
ResolvedType::extend_unique(results, ResolvedType::from_classes(resolved));
25302545
}
2531-
walk_statements_for_assignments(catch.block.statements.iter(), ctx, results, true);
2546+
// Same logic: when the cursor is inside this catch block,
2547+
// treat preceding assignments as unconditional.
2548+
let catch_span = catch.block.span();
2549+
let cursor_in_catch = ctx.cursor_offset >= catch_span.start.offset
2550+
&& ctx.cursor_offset <= catch_span.end.offset;
2551+
walk_statements_for_assignments(
2552+
catch.block.statements.iter(),
2553+
ctx,
2554+
results,
2555+
!cursor_in_catch,
2556+
);
25322557
}
25332558
if let Some(finally) = &try_stmt.finally_clause {
2534-
walk_statements_for_assignments(finally.block.statements.iter(), ctx, results, true);
2559+
let finally_span = finally.block.span();
2560+
let cursor_in_finally = ctx.cursor_offset >= finally_span.start.offset
2561+
&& ctx.cursor_offset <= finally_span.end.offset;
2562+
walk_statements_for_assignments(
2563+
finally.block.statements.iter(),
2564+
ctx,
2565+
results,
2566+
!cursor_in_finally,
2567+
);
25352568
}
25362569
}
25372570

src/diagnostics/unknown_members.rs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4902,4 +4902,137 @@ class Svc {
49024902
"Should flag 'unknown', got: {diags:?}"
49034903
);
49044904
}
4905+
4906+
#[test]
4907+
fn no_false_positive_when_variable_reassigned_inside_try_block() {
4908+
// When a variable is reassigned inside a `try` block, accesses
4909+
// after the reassignment (still inside the try) should resolve
4910+
// against the new type, not the original.
4911+
let php = r#"<?php
4912+
class LuxplusCustomer {
4913+
public function getName(): string { return ''; }
4914+
}
4915+
class MollieCustomer {
4916+
public function createPayment(string $data): MolliePayment { return new MolliePayment(); }
4917+
}
4918+
class MolliePayment {
4919+
public function getCheckoutUrl(): string { return ''; }
4920+
}
4921+
class MollieClient {
4922+
public function getOrCreateCustomer(LuxplusCustomer $c): MollieCustomer { return new MollieCustomer(); }
4923+
}
4924+
class Gateway {
4925+
public function charge(LuxplusCustomer $customer): void {
4926+
$client = new MollieClient();
4927+
try {
4928+
$customer = $client->getOrCreateCustomer($customer);
4929+
$molliePayment = $customer->createPayment('data');
4930+
$url = $molliePayment->getCheckoutUrl();
4931+
} catch (\Exception $e) {
4932+
}
4933+
}
4934+
}
4935+
"#;
4936+
let backend = Backend::new_test();
4937+
let diags = collect(&backend, "file:///test.php", php);
4938+
assert!(
4939+
diags.is_empty(),
4940+
"expected no diagnostics for reassigned variable inside try block, got: {diags:?}"
4941+
);
4942+
}
4943+
4944+
#[test]
4945+
fn flags_unknown_member_after_reassignment_inside_try_block() {
4946+
// The flip side: after reassignment inside a try block, members
4947+
// from the OLD type that don't exist on the NEW type should be
4948+
// flagged.
4949+
let php = r#"<?php
4950+
class OriginalType {
4951+
public function onlyOnOriginal(): void {}
4952+
}
4953+
class ReplacementType {
4954+
public function onlyOnReplacement(): void {}
4955+
}
4956+
class Service {
4957+
public function process(OriginalType $var): void {
4958+
try {
4959+
$var = new ReplacementType();
4960+
$var->onlyOnOriginal();
4961+
} catch (\Exception $e) {
4962+
}
4963+
}
4964+
}
4965+
"#;
4966+
let backend = Backend::new_test();
4967+
let diags = collect(&backend, "file:///test.php", php);
4968+
assert!(
4969+
diags
4970+
.iter()
4971+
.any(|d| d.message.contains("onlyOnOriginal")
4972+
&& d.message.contains("ReplacementType")),
4973+
"expected diagnostic for onlyOnOriginal() on ReplacementType after reassignment in try, got: {diags:?}"
4974+
);
4975+
}
4976+
4977+
#[test]
4978+
fn try_block_reassignment_is_conditional_after_try() {
4979+
// After the try/catch block, the variable could be either the
4980+
// original type (if the try threw before the reassignment) or
4981+
// the new type. Both types' members should be accepted.
4982+
let php = r#"<?php
4983+
class TypeA {
4984+
public function methodA(): void {}
4985+
}
4986+
class TypeB {
4987+
public function methodB(): void {}
4988+
}
4989+
class Svc {
4990+
public function run(TypeA $var): void {
4991+
try {
4992+
$var = new TypeB();
4993+
} catch (\Exception $e) {
4994+
}
4995+
$var->methodA();
4996+
$var->methodB();
4997+
}
4998+
}
4999+
"#;
5000+
let backend = Backend::new_test();
5001+
let diags = collect(&backend, "file:///test.php", php);
5002+
assert!(
5003+
diags.is_empty(),
5004+
"after try/catch, both original and reassigned types should be accepted, got: {diags:?}"
5005+
);
5006+
}
5007+
5008+
#[test]
5009+
fn catch_block_variable_reassignment_tracked() {
5010+
// Variable reassignment inside a catch block should also be
5011+
// tracked when the cursor is inside the catch block.
5012+
let php = r#"<?php
5013+
class ErrorResult {
5014+
public function getErrorCode(): int { return 0; }
5015+
}
5016+
class SuccessResult {
5017+
public function getData(): string { return ''; }
5018+
}
5019+
class Handler {
5020+
public function handle(): void {
5021+
$result = new SuccessResult();
5022+
try {
5023+
$result->getData();
5024+
} catch (\Exception $e) {
5025+
$result = new ErrorResult();
5026+
$result->getErrorCode();
5027+
}
5028+
}
5029+
}
5030+
"#;
5031+
let backend = Backend::new_test();
5032+
let diags = collect(&backend, "file:///test.php", php);
5033+
assert!(
5034+
diags.is_empty(),
5035+
"expected no diagnostics for reassigned variable inside catch block, got: {diags:?}"
5036+
);
5037+
}
49055038
}

0 commit comments

Comments
 (0)