Skip to content

Commit 4b98c57

Browse files
committed
Implement unset() variable tracking
1 parent ed4e4d4 commit 4b98c57

6 files changed

Lines changed: 1063 additions & 29 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- **Template parameter bound resolution.** When a property or variable type is a `@template` parameter (e.g. `TNode`), the resolver falls back to the upper bound declared via `of` (e.g. `@template TNode of SomeClass`) for completion and go-to-definition.
1414
- **Transitive interface inheritance in go-to-implementation.** If `InterfaceB extends InterfaceA` and `ClassC implements InterfaceB`, go-to-implementation on `InterfaceA` now finds `ClassC`. Works through arbitrary depth and with interfaces that extend multiple parents.
1515
- **Switch statement variable type tracking.** Variables assigned inside `switch` case bodies now resolve their types. Both brace-delimited and colon-delimited (`switch(): … endswitch;`) forms are supported, and all cases contribute to a union type.
16+
- **`unset()` variable tracking.** After `unset($var)`, the variable no longer appears in name suggestions and `$var->` does not resolve to its previous type. Re-assignment after `unset` restores the variable with the new type. Conditional `unset` (inside `if` blocks) is handled conservatively, keeping the variable because it might still exist.
1617

1718
### Fixed
1819

docs/todo.md

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -92,22 +92,16 @@ variable's concrete type.
9292
(the class the subject resolved to) rather than the class that declares
9393
the method.
9494

95-
### 15. `unset()` tracking
96-
**Priority: Medium**
97-
98-
`unset($var)` removes a variable from scope, and `unset($arr['key'])` removes
99-
a key from an array shape. Neither is tracked today.
95+
### ~~15. `unset()` tracking (variable scope)~~
10096

101-
- **Variable scope.** After `unset($x)`, the variable `$x` should no longer
102-
appear in variable name suggestions, and `$x->` should not resolve to the
103-
type it had before the `unset`.
104-
- **Array shape keys.** After `unset($config['host'])`, the key `host` should
105-
no longer appear in `$config['` key completions, and the inferred shape
106-
should reflect its removal.
97+
Variable scope tracking is complete. After `unset($x)`, the variable `$x`
98+
no longer appears in variable name suggestions and `$x->` does not resolve
99+
to the type it had before the `unset`. Re-assignment after `unset` restores
100+
the variable with its new type. Conditional `unset` (inside `if` blocks)
101+
is treated conservatively: the variable is kept because it might still exist.
107102

108-
Both cases require the assignment/variable scanner in
109-
`completion/variable_resolution.rs` to recognise `unset(...)` statements
110-
and update its tracking accordingly.
103+
**Remaining:** Array shape key removal (`unset($config['host'])` removing
104+
the `host` key from `$config['` key completions) is not yet tracked.
111105

112106
### 20. Non-`$this` property access in text-based assignment path
113107
**Priority: Low**

example.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1297,6 +1297,53 @@ public function demo(): void
12971297
}
12981298

12991299

1300+
1301+
// ── unset() Tracking ────────────────────────────────────────────────────────
1302+
1303+
/**
1304+
* Demonstrates that `unset($var)` removes the variable from scope.
1305+
*
1306+
* After `unset($x)`, completion on `$x->` should produce no results.
1307+
* Re-assigning the variable restores its type.
1308+
*/
1309+
class UnsetDemo
1310+
{
1311+
public function basicUnset(): void
1312+
{
1313+
$user = new User('Alice', 'alice@example.com');
1314+
$user->getEmail(); // resolves to User
1315+
unset($user);
1316+
//$user->
1317+
// Try: $user-> — no completions (variable was unset)
1318+
}
1319+
1320+
public function reassignAfterUnset(): void
1321+
{
1322+
$item = new User('Bob', 'bob@example.com');
1323+
unset($item);
1324+
$item = new AdminUser('Admin', 'admin@example.com');
1325+
$item->grantPermission('edit'); // resolves to AdminUser
1326+
}
1327+
1328+
public function unsetMultiple(): void
1329+
{
1330+
$user = new User('A', 'a@b.com');
1331+
$profile = new UserProfile($user);
1332+
unset($user, $profile);
1333+
// Try: $user-> — no completions
1334+
// Try: $profile-> — no completions
1335+
}
1336+
1337+
public function unsetOnlyAffectsTarget(): void
1338+
{
1339+
$user = new User('A', 'a@b.com');
1340+
$profile = new UserProfile($user);
1341+
unset($user);
1342+
$profile->getDisplayName(); // still resolves to UserProfile
1343+
}
1344+
}
1345+
1346+
13001347
// ═══════════════════════════════════════════════════════════════════════════
13011348
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
13021349
// ┃ SCAFFOLDING — Supporting definitions below this line. ┃

src/completion/variable_completion.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,16 @@ fn collect_from_statements<'b>(
486486
}
487487
}
488488
}
489+
// ── unset($var) ──
490+
// When `unset($var)` appears before the cursor, the variable
491+
// is no longer in scope and should not be suggested.
492+
Statement::Unset(unset_stmt) => {
493+
for val in unset_stmt.values.iter() {
494+
if let Expression::Variable(Variable::Direct(dv)) = val {
495+
vars.remove(&dv.name.to_string());
496+
}
497+
}
498+
}
489499
// Skip class/function/namespace declarations (they have their
490500
// own scopes handled by find_scope_and_collect).
491501
Statement::Class(_)

src/completion/variable_resolution.rs

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -101,32 +101,29 @@ impl Backend {
101101
if ctx.cursor_offset < start || ctx.cursor_offset > end {
102102
continue;
103103
}
104-
let results = Self::resolve_variable_in_members(class.members.iter(), ctx);
105-
if !results.is_empty() {
106-
return results;
107-
}
104+
// The cursor is inside this class body. PHP method
105+
// scopes are isolated — they cannot access variables
106+
// from enclosing or top-level code. Return whatever
107+
// the member scan found (even if empty, e.g. after
108+
// `unset($var)`), and never fall through to the
109+
// top-level walk.
110+
return Self::resolve_variable_in_members(class.members.iter(), ctx);
108111
}
109112
Statement::Interface(iface) => {
110113
let start = iface.left_brace.start.offset;
111114
let end = iface.right_brace.end.offset;
112115
if ctx.cursor_offset < start || ctx.cursor_offset > end {
113116
continue;
114117
}
115-
let results = Self::resolve_variable_in_members(iface.members.iter(), ctx);
116-
if !results.is_empty() {
117-
return results;
118-
}
118+
return Self::resolve_variable_in_members(iface.members.iter(), ctx);
119119
}
120120
Statement::Enum(enum_def) => {
121121
let start = enum_def.left_brace.start.offset;
122122
let end = enum_def.right_brace.end.offset;
123123
if ctx.cursor_offset < start || ctx.cursor_offset > end {
124124
continue;
125125
}
126-
let results = Self::resolve_variable_in_members(enum_def.members.iter(), ctx);
127-
if !results.is_empty() {
128-
return results;
129-
}
126+
return Self::resolve_variable_in_members(enum_def.members.iter(), ctx);
130127
}
131128
Statement::Namespace(ns) => {
132129
let results = Self::resolve_variable_in_statements(ns.statements().iter(), ctx);
@@ -142,6 +139,9 @@ impl Backend {
142139
let body_start = func.body.left_brace.start.offset;
143140
let body_end = func.body.right_brace.end.offset;
144141
if ctx.cursor_offset >= body_start && ctx.cursor_offset <= body_end {
142+
// The cursor is inside this function body. PHP
143+
// function scopes are isolated, so return the
144+
// result directly (even if empty after `unset`).
145145
let mut results: Vec<ClassInfo> = Vec::new();
146146
Self::resolve_closure_params(&func.parameter_list, ctx, &mut results);
147147
Self::walk_statements_for_assignments(
@@ -150,9 +150,7 @@ impl Backend {
150150
&mut results,
151151
false,
152152
);
153-
if !results.is_empty() {
154-
return results;
155-
}
153+
return results;
156154
}
157155
}
158156
_ => {}
@@ -391,6 +389,26 @@ impl Backend {
391389
);
392390
}
393391
}
392+
// ── unset($var) tracking ──
393+
// When `unset($var)` appears unconditionally before the
394+
// cursor, the variable no longer has a type. Clear all
395+
// previously accumulated results so that `$var->` does
396+
// not resolve to the type it had before the unset.
397+
//
398+
// Inside conditional branches (`conditional == true`)
399+
// the variable *might* still exist, so we leave the
400+
// results untouched.
401+
Statement::Unset(unset_stmt) => {
402+
if !conditional {
403+
for val in unset_stmt.values.iter() {
404+
if let Expression::Variable(Variable::Direct(dv)) = val
405+
&& dv.name == ctx.var_name
406+
{
407+
results.clear();
408+
}
409+
}
410+
}
411+
}
394412
_ => {}
395413
}
396414
}

0 commit comments

Comments
 (0)