Skip to content

Commit 613e6cf

Browse files
committed
Fix variable reassignment loses type when parameter name is reused
1 parent a6c7e75 commit 613e6cf

8 files changed

Lines changed: 241 additions & 46 deletions

File tree

docs/CHANGELOG.md

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

1010
### Fixed
1111

12+
- **Variable type after reassignment.** When a method parameter is reassigned mid-body (e.g. `$file = $result->getFile()`), subsequent member accesses now resolve against the new type instead of the original parameter type. Previously the diagnostic subject cache reused the first resolution for the entire method scope, producing false-positive "not found" warnings on members that exist only on the reassigned type.
1213
- **Null-safe method chain resolution.** Null-safe method calls (`$obj?->method()`) now resolve the return type correctly for variable type inference, including cross-file chains. Previously `?->` calls were ignored by the RHS resolution pipeline, losing the type for any variable assigned from a null-safe chain.
1314
- **Nullable and generic types in class lookup.** Variables typed as `?ClassName` or `Collection<Item>` now resolve correctly across all code paths. Previously the `?` prefix and generic parameters were not stripped before class lookup, causing the type engine to treat them as unknown types. This fixes completion, hover, go-to-definition, and false-positive diagnostics for any variable whose type uses the nullable shorthand or carries generic arguments.
1415
- **`@see` references with qualified class names.** Docblock `@see Fully\Qualified\ClassName` references no longer have the file's namespace prepended, which previously produced doubled names like `App\Models\App\Models\User`. Qualified names in `@see` tags are now treated as fully-qualified.

docs/todo.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,6 @@ unlikely to move the needle for most users.
100100

101101
| # | Item | Impact | Effort |
102102
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | ----------- |
103-
| | **[Bugs](todo/bugs.md)** | | |
104-
| B4 | [Variable reassignment loses type when parameter name is reused](todo/bugs.md#b4-variable-reassignment-loses-type-when-parameter-name-is-reused) | Medium | Medium |
105103
| | **[Completion](todo/completion.md)** | | |
106104
| C1 | Array functions needing new code paths | Medium | High |
107105
| C10 | [Lazy documentation via `completionItem/resolve`](todo/completion.md#c10-lazy-documentation-via-completionitemresolve) | Medium | Medium |

docs/todo/bugs.md

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,4 @@ within the same impact tier.
1515

1616
---
1717

18-
#### B4. Variable reassignment loses type when parameter name is reused
19-
20-
| | |
21-
|---|---|
22-
| **Impact** | Medium |
23-
| **Effort** | Medium |
24-
25-
When a method parameter is reassigned mid-body, PHPantom sometimes
26-
continues to use the parameter's original type instead of the new
27-
assignment's type.
28-
29-
**Observed:** In `FileUploadService::uploadFile()`, the `$file`
30-
parameter is typed `UploadedFile`. Later, `$file = $result->getFile()`
31-
reassigns it to a different type. PHPantom still resolves `$file->id`
32-
and `$file->name` against `UploadedFile` instead of the model returned
33-
by `getFile()`. This produces 2 false-positive "not found" diagnostics.
34-
35-
**Fix:** The variable resolution pipeline should prefer the most recent
36-
assignment when multiple definitions exist for the same variable name
37-
within the same scope at the cursor offset.
38-
18+
No outstanding items.

src/analyse.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ pub async fn run(options: AnalyseOptions) -> i32 {
233233
if let Some(sm) = backend.symbol_maps.read().get(uri.as_str()) {
234234
crate::completion::resolver::set_diagnostic_subject_cache_scopes(
235235
sm.scopes.clone(),
236+
sm.var_defs.clone(),
236237
);
237238
}
238239

src/completion/resolver.rs

Lines changed: 106 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -155,21 +155,32 @@ impl<'a> VarResolutionCtx<'a> {
155155
/// (unknown_member, argument_count) share results instead of
156156
/// re-resolving the same subjects independently.
157157
///
158-
/// The cache key is `(subject_text, access_kind, scope_start)` where
159-
/// `scope_start` is the byte offset of the innermost enclosing
160-
/// function/method/closure body. This ensures that two methods in
161-
/// the same class that both use `$order->` get independent cache
162-
/// entries when `$order` has a different type in each method.
158+
/// The cache key is `(subject_text, access_kind, scope_start,
159+
/// var_def_offset)` where `scope_start` is the byte offset of the
160+
/// innermost enclosing function/method/closure body and
161+
/// `var_def_offset` is the `effective_from` of the active variable
162+
/// definition (or `0` for non-variable subjects). This ensures that
163+
/// two methods in the same class that both use `$order->` get
164+
/// independent cache entries, and that accesses before vs. after a
165+
/// variable reassignment within the same method also get independent
166+
/// entries.
163167
///
164-
/// Scope boundaries are stored alongside the cache and set by
165-
/// [`set_diagnostic_subject_cache_scopes`].
166-
type DiagSubjectCache = HashMap<(String, AccessKind, u32), Vec<Arc<ClassInfo>>>;
168+
/// Scope boundaries and variable definitions are stored alongside the
169+
/// cache and set by [`set_diagnostic_subject_cache_scopes`].
170+
type DiagSubjectCache = HashMap<(String, AccessKind, u32, u32), Vec<Arc<ClassInfo>>>;
171+
172+
/// File-level data stored alongside the diagnostic subject cache so
173+
/// that [`resolve_target_classes`] can compute the enclosing scope and
174+
/// active variable definition from the `cursor_offset` without needing
175+
/// a reference to the [`SymbolMap`].
176+
struct DiagSubjectCacheFileData {
177+
/// Scope boundaries `(start_offset, end_offset)`.
178+
scopes: Vec<(u32, u32)>,
179+
/// Variable definition sites, cloned from the [`SymbolMap`].
180+
var_defs: Vec<crate::symbol_map::VarDefSite>,
181+
}
167182

168-
/// Scope boundaries `(start_offset, end_offset)` for the current file,
169-
/// stored alongside the diagnostic subject cache so that
170-
/// [`resolve_target_classes`] can compute the enclosing scope from the
171-
/// `cursor_offset` without needing a reference to the [`SymbolMap`].
172-
type DiagSubjectCacheState = (DiagSubjectCache, Vec<(u32, u32)>);
183+
type DiagSubjectCacheState = (DiagSubjectCache, DiagSubjectCacheFileData);
173184

174185
thread_local! {
175186
static DIAG_SUBJECT_CACHE: RefCell<Option<DiagSubjectCacheState>> = const { RefCell::new(None) };
@@ -205,24 +216,40 @@ pub(crate) fn with_diagnostic_subject_cache() -> DiagSubjectCacheGuard {
205216
return DiagSubjectCacheGuard { owns_cache: false };
206217
}
207218
DIAG_SUBJECT_CACHE.with(|cell| {
208-
*cell.borrow_mut() = Some((HashMap::new(), Vec::new()));
219+
*cell.borrow_mut() = Some((
220+
HashMap::new(),
221+
DiagSubjectCacheFileData {
222+
scopes: Vec::new(),
223+
var_defs: Vec::new(),
224+
},
225+
));
209226
});
210227
DiagSubjectCacheGuard { owns_cache: true }
211228
}
212229

213-
/// Provide scope boundaries for the active diagnostic subject cache.
230+
/// Provide scope boundaries and variable definitions for the active
231+
/// diagnostic subject cache.
214232
///
215233
/// Must be called while a [`DiagSubjectCacheGuard`] is alive. The
216234
/// scopes are `(start_offset, end_offset)` pairs for every
217235
/// function, method, closure, and arrow function body in the file.
218236
/// They are used to compute the enclosing scope for each
219237
/// `cursor_offset`, ensuring that same-named variables in different
220238
/// methods resolve independently.
221-
pub(crate) fn set_diagnostic_subject_cache_scopes(scopes: Vec<(u32, u32)>) {
239+
///
240+
/// The `var_defs` are cloned from the [`SymbolMap`] and used to
241+
/// compute the active variable definition at each cursor offset,
242+
/// ensuring that accesses before and after a variable reassignment
243+
/// within the same method get independent cache entries.
244+
pub(crate) fn set_diagnostic_subject_cache_scopes(
245+
scopes: Vec<(u32, u32)>,
246+
var_defs: Vec<crate::symbol_map::VarDefSite>,
247+
) {
222248
DIAG_SUBJECT_CACHE.with(|cell| {
223249
let mut borrow = cell.borrow_mut();
224-
if let Some((_map, stored_scopes)) = borrow.as_mut() {
225-
*stored_scopes = scopes;
250+
if let Some((_map, file_data)) = borrow.as_mut() {
251+
file_data.scopes = scopes;
252+
file_data.var_defs = var_defs;
226253
}
227254
});
228255
}
@@ -236,9 +263,9 @@ fn diag_cache_enclosing_scope(cursor_offset: u32) -> u32 {
236263
DIAG_SUBJECT_CACHE.with(|cell| {
237264
let borrow = cell.borrow();
238265
match borrow.as_ref() {
239-
Some((_map, scopes)) => {
266+
Some((_map, file_data)) => {
240267
let mut best: u32 = 0;
241-
for &(start, end) in scopes {
268+
for &(start, end) in &file_data.scopes {
242269
if start <= cursor_offset && cursor_offset <= end && start > best {
243270
best = start;
244271
}
@@ -250,6 +277,58 @@ fn diag_cache_enclosing_scope(cursor_offset: u32) -> u32 {
250277
})
251278
}
252279

280+
/// Compute the `var_def_offset` discriminator for a subject at a given
281+
/// cursor offset.
282+
///
283+
/// For variable-based subjects (starting with `$`, excluding `$this`),
284+
/// returns the `effective_from` offset of the most recent variable
285+
/// definition visible at `cursor_offset`. For non-variable subjects,
286+
/// returns `0`.
287+
///
288+
/// This ensures that the diagnostic subject cache distinguishes
289+
/// accesses to the same variable before and after a reassignment.
290+
fn diag_cache_var_def_offset(subject: &str, cursor_offset: u32) -> u32 {
291+
if !subject.starts_with('$') || subject.starts_with("$this") {
292+
return 0;
293+
}
294+
// Extract the bare variable name without '$' (e.g. "file" from
295+
// "$file" or "$file->foo()").
296+
let after_dollar = &subject[1..];
297+
let var_name = after_dollar
298+
.find("->")
299+
.map(|i| &after_dollar[..i])
300+
.unwrap_or(after_dollar);
301+
302+
DIAG_SUBJECT_CACHE.with(|cell| {
303+
let borrow = cell.borrow();
304+
match borrow.as_ref() {
305+
Some((_map, file_data)) => {
306+
let scope_start = {
307+
let mut best: u32 = 0;
308+
for &(start, end) in &file_data.scopes {
309+
if start <= cursor_offset && cursor_offset <= end && start > best {
310+
best = start;
311+
}
312+
}
313+
best
314+
};
315+
file_data
316+
.var_defs
317+
.iter()
318+
.rev()
319+
.find(|d| {
320+
d.name == var_name
321+
&& d.scope_start == scope_start
322+
&& d.effective_from <= cursor_offset
323+
})
324+
.map(|d| d.effective_from)
325+
.unwrap_or(0)
326+
}
327+
None => 0,
328+
}
329+
})
330+
}
331+
253332
/// Resolve a completion subject to all candidate class types.
254333
///
255334
/// When a variable is assigned different types in conditional branches
@@ -271,7 +350,13 @@ pub(crate) fn resolve_target_classes(
271350
) -> Vec<Arc<ClassInfo>> {
272351
// ── Fast path: check the thread-local diagnostic cache ──────
273352
let scope_start = diag_cache_enclosing_scope(ctx.cursor_offset);
274-
let cache_key = (subject.to_string(), access_kind, scope_start);
353+
let var_def_offset = diag_cache_var_def_offset(subject, ctx.cursor_offset);
354+
let cache_key = (
355+
subject.to_string(),
356+
access_kind,
357+
scope_start,
358+
var_def_offset,
359+
);
275360
let cached = DIAG_SUBJECT_CACHE.with(|cell| {
276361
let borrow = cell.borrow();
277362
borrow

src/diagnostics/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,10 @@ impl Backend {
575575
// can distinguish variables in different methods of the same
576576
// class (prevents cross-method cache pollution).
577577
if let Some(sm) = self.symbol_maps.read().get(uri_str) {
578-
crate::completion::resolver::set_diagnostic_subject_cache_scopes(sm.scopes.clone());
578+
crate::completion::resolver::set_diagnostic_subject_cache_scopes(
579+
sm.scopes.clone(),
580+
sm.var_defs.clone(),
581+
);
579582
}
580583

581584
self.collect_slow_diagnostics(uri_str, content, &mut slow_diagnostics);

src/diagnostics/unknown_members.rs

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,21 @@ enum ScopeKey {
108108
}
109109

110110
/// Cache key combining the subject text, access kind, and scope.
111+
///
112+
/// For variable-based subjects (starting with `$`, excluding `$this`),
113+
/// `var_def_offset` distinguishes accesses that fall under different
114+
/// definitions of the same variable. Without this, the cache would
115+
/// return the parameter type for accesses after a reassignment.
111116
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
112117
struct SubjectCacheKey {
113118
subject_text: String,
114119
access_kind: AccessKind,
115120
scope: ScopeKey,
121+
/// The `effective_from` offset of the active variable definition at
122+
/// the point of access, or `0` for non-variable subjects. This
123+
/// ensures that accesses before and after a reassignment get
124+
/// separate cache entries.
125+
var_def_offset: u32,
116126
}
117127

118128
/// The outcome of resolving a subject for diagnostic purposes.
@@ -292,7 +302,10 @@ impl Backend {
292302
// distinguish variables in different methods of the same class.
293303
// In production this is already set by the outer caller; for
294304
// standalone calls (tests, benchmarks) set it here.
295-
crate::completion::resolver::set_diagnostic_subject_cache_scopes(symbol_map.scopes.clone());
305+
crate::completion::resolver::set_diagnostic_subject_cache_scopes(
306+
symbol_map.scopes.clone(),
307+
symbol_map.var_defs.clone(),
308+
);
296309

297310
// ── Subject resolution cache for this diagnostic pass ───────────
298311
let mut subject_cache: SubjectCache = HashMap::new();
@@ -352,10 +365,30 @@ impl Backend {
352365
let fn_scope_start = symbol_map.find_enclosing_scope(span.start);
353366

354367
// ── Look up or populate the subject cache ───────────────────
368+
// For variable subjects (excluding $this), compute the
369+
// active definition offset so that accesses before and
370+
// after a reassignment get separate cache entries (B4).
371+
let var_def_offset =
372+
if subject_text.starts_with('$') && !subject_text.starts_with("$this") {
373+
// Extract the bare variable name (e.g. "$file" from
374+
// "$file" or from a chain like "$file->foo()").
375+
let var_name = subject_text
376+
.find("->")
377+
.map(|i| &subject_text[..i])
378+
.unwrap_or(subject_text);
379+
symbol_map.active_var_def_offset(
380+
&var_name[1..], // strip leading '$'
381+
span.start,
382+
)
383+
} else {
384+
0
385+
};
386+
355387
let cache_key = SubjectCacheKey {
356388
subject_text: subject_text.clone(),
357389
access_kind,
358390
scope: scope_key_for(current_class, fn_scope_start),
391+
var_def_offset,
359392
};
360393

361394
let outcome = subject_cache
@@ -3511,4 +3544,83 @@ class Service {
35113544
"expected exactly one 'foo' diagnostic (in method b), got: {foo_diags:?}"
35123545
);
35133546
}
3547+
3548+
#[test]
3549+
fn no_false_positive_when_parameter_is_reassigned() {
3550+
// Regression test for B4: when a method parameter is reassigned
3551+
// mid-body, PHPantom should resolve subsequent accesses against
3552+
// the new type, not the original parameter type.
3553+
//
3554+
// Before the fix, the subject cache keyed by (subject_text,
3555+
// access_kind, scope) would cache the parameter type on the
3556+
// first `$file->` encounter and reuse it for accesses after
3557+
// the reassignment, producing false-positive "unknown member"
3558+
// diagnostics.
3559+
let php = r#"<?php
3560+
class UploadedFile {
3561+
public string $originalName;
3562+
}
3563+
class FileModel {
3564+
public int $id;
3565+
public string $name;
3566+
}
3567+
class Result {
3568+
public function getFile(): FileModel { return new FileModel(); }
3569+
}
3570+
class FileUploadService {
3571+
public function uploadFile(UploadedFile $file): void {
3572+
$file->originalName;
3573+
$result = new Result();
3574+
$file = $result->getFile();
3575+
$file->id;
3576+
$file->name;
3577+
}
3578+
}
3579+
"#;
3580+
let backend = Backend::new_test();
3581+
let diags = collect(&backend, "file:///test.php", php);
3582+
assert!(
3583+
diags.is_empty(),
3584+
"expected no false positives when parameter is reassigned mid-body, got: {diags:?}"
3585+
);
3586+
}
3587+
3588+
#[test]
3589+
fn flags_unknown_member_after_reassignment() {
3590+
// The flip side of B4: after reassignment, members from the
3591+
// NEW type that don't exist should still be flagged.
3592+
let php = r#"<?php
3593+
class TypeA {
3594+
public function onlyOnA(): void {}
3595+
}
3596+
class TypeB {
3597+
public function onlyOnB(): void {}
3598+
}
3599+
class Service {
3600+
public function process(TypeA $var): void {
3601+
$var->onlyOnA();
3602+
$var = new TypeB();
3603+
$var->onlyOnA();
3604+
}
3605+
}
3606+
"#;
3607+
let backend = Backend::new_test();
3608+
let diags = collect(&backend, "file:///test.php", php);
3609+
assert!(
3610+
diags
3611+
.iter()
3612+
.any(|d| d.message.contains("onlyOnA") && d.message.contains("TypeB")),
3613+
"expected diagnostic for onlyOnA() on TypeB after reassignment, got: {diags:?}"
3614+
);
3615+
// Exactly one diagnostic — the post-reassignment access.
3616+
let relevant: Vec<_> = diags
3617+
.iter()
3618+
.filter(|d| d.message.contains("onlyOnA"))
3619+
.collect();
3620+
assert_eq!(
3621+
relevant.len(),
3622+
1,
3623+
"expected exactly one 'onlyOnA' diagnostic (after reassignment), got: {relevant:?}"
3624+
);
3625+
}
35143626
}

0 commit comments

Comments
 (0)