Skip to content

Commit aae5193

Browse files
committed
Fix regression from 500998f
1 parent 500998f commit aae5193

8 files changed

Lines changed: 136 additions & 74 deletions

File tree

docs/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5555
- **Diagnostic code identifiers.** All diagnostic codes now use a consistent `snake_case` noun-phrase scheme: `unknown_variable`, `type_mismatch_argument`, `argument_count_mismatch`, `deprecated_usage`, `missing_implementation`. Users with editor filters matching on these codes will need to update them.
5656
- **Lower memory usage for lazily-loaded files.** Vendor and stub files no longer store per-file import tables and namespace maps after parsing, and go-to-implementation uses a dedicated reverse-inheritance index instead of scanning all parsed files.
5757
- **Lower memory usage for variable type tracking.**
58+
- **Faster variable name completion.** Variable name suggestions now use the precomputed symbol map instead of re-parsing the file. Foreach iteration variables correctly persist after the loop (matching PHP semantics), `@var` docblock variable names are included, and `unset()` removes variables from suggestions.
59+
- **Faster go-to-definition for variables.** Variable definition lookup no longer re-parses the file as a fallback; the precomputed symbol map handles all cases.
5860
- **Updated embedded phpstorm-stubs.**
5961

6062
### Fixed

docs/todo/refactor.md

Lines changed: 0 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -214,34 +214,6 @@ Each item must include:
214214

215215
# Outstanding items
216216

217-
## Intentional overlap (reference, not actionable)
218-
219-
These are parallel systems that exist for valid reasons. Documented here so
220-
they are factored into future design decisions.
221-
222-
### `uri_classes_index` vs `fqn_class_index` vs `fqn_uri_index`
223-
224-
Three maps storing overlapping class data:
225-
- `uri_classes_index`: URI → `Vec<Arc<ClassInfo>>` (needed for "all classes in this file")
226-
- `fqn_class_index`: FQN → `Arc<ClassInfo>` (O(1) FQN lookup)
227-
- `fqn_uri_index`: FQN → URI string (survives `didClose`, enables re-loading)
228-
229-
Data is shared via `Arc`. Each serves a distinct query pattern.
230-
231-
### PathBuf ↔ URI round-tripping
232-
233-
The classmap scanner produces `PathBuf`, converts to URI strings for
234-
`fqn_uri_index`, then at lookup time parses back to `PathBuf` for file reading.
235-
A minor inefficiency, not a parallel system.
236-
237-
### `find_class_in_ast_map` linear scan fallback
238-
239-
`src/util.rs` L1436–1447: After the O(1) `fqn_class_index` lookup fails, falls
240-
back to linear scan of `uri_classes_index`. Covers race conditions during initial
241-
indexing. Legitimate safety net.
242-
243-
---
244-
245217
## Redundant backwards text walkers
246218

247219
These functions in `src/completion/source/helpers.rs` scan backward with `rfind`
@@ -278,21 +250,6 @@ The SymbolMap is built once per file in `update_ast_inner` and stores symbol
278250
spans, variable definitions, scope boundaries, and call sites. Several features
279251
re-parse the AST to extract information the SymbolMap already has.
280252

281-
### Variable definition lookup
282-
283-
The SymbolMap has `var_defs` with `find_var_definition()`, but
284-
`definition/variable/mod.rs` ignores it and does its own full AST walk via
285-
`find_variable_definition_in_program()`. The SymbolMap's `var_defs` are used
286-
elsewhere (hover, forward walk) but the go-to-definition feature duplicates
287-
the work.
288-
289-
### Variables-in-scope collection
290-
291-
`collect_variables_in_scope` in `completion.rs` re-parses the AST to find all
292-
variables visible at the cursor. The SymbolMap's `var_defs` already has every
293-
variable definition site with scope boundaries; this could be a filtered query
294-
on `var_defs` instead of a full AST walk.
295-
296253
### Scope boundary detection
297254

298255
The SymbolMap stores `scopes` (function/closure boundaries) and provides

src/completion/variable/completion.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use tower_lsp::lsp_types::*;
1010

1111
use crate::Backend;
1212
use crate::symbol_map::SymbolMap;
13+
use crate::symbol_map::VarDefKind;
1314
use crate::util::position_to_byte_offset;
1415

1516
impl Backend {
@@ -252,6 +253,26 @@ fn collect_variables_from_symbol_map(
252253
}
253254
}
254255

256+
// Remove variables whose most recent definition before cursor is an Unset.
257+
let mut to_remove = Vec::new();
258+
for var_name_with_dollar in &vars {
259+
let name_without_dollar = &var_name_with_dollar[1..];
260+
let last_def = map.var_defs.iter().rfind(|d| {
261+
d.name == name_without_dollar
262+
&& visible_scopes.contains(&d.scope_start)
263+
&& d.effective_from <= cursor_offset
264+
});
265+
if let Some(d) = last_def
266+
&& d.kind == VarDefKind::Unset
267+
&& (d.block_end == u32::MAX || cursor_offset <= d.block_end)
268+
{
269+
to_remove.push(var_name_with_dollar.clone());
270+
}
271+
}
272+
for name in to_remove {
273+
vars.remove(&name);
274+
}
275+
255276
// Add `$this` when inside a non-static method. In PHP, `$this`
256277
// is automatically available inside closures defined in instance
257278
// methods (since PHP 5.4), so we check ALL enclosing scopes.

src/symbol_map/docblock.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,62 @@ pub(super) fn extract_param_var_spans(docblock: &str, base_offset: u32) -> Vec<(
353353
results
354354
}
355355

356+
/// Scan a docblock for `@var Type $varName` tokens and return
357+
/// `(name_without_dollar, file_byte_offset_of_dollar)` pairs.
358+
///
359+
/// These are used by the symbol-map extraction to emit
360+
/// [`VarDefSite`](super::VarDefSite) entries for inline `@var`
361+
/// docblocks so the forward walker treats them as variable definitions.
362+
pub(super) fn extract_var_docblock_var_spans(
363+
docblock: &str,
364+
base_offset: u32,
365+
) -> Vec<(String, u32)> {
366+
let base_span = Span::new(
367+
FileId::zero(),
368+
Position::new(base_offset),
369+
Position::new(base_offset + docblock.len() as u32),
370+
);
371+
let Some(info) = parse_docblock(docblock, base_span) else {
372+
return Vec::new();
373+
};
374+
375+
let mut results = Vec::new();
376+
377+
for tag in &info.tags {
378+
let is_var = matches!(
379+
tag.kind,
380+
TagKind::Var | TagKind::PhpstanVar | TagKind::PsalmVar
381+
);
382+
if !is_var {
383+
continue;
384+
}
385+
386+
// The description is `TypeHint $varName desc` or just `$varName`.
387+
// Find the `$` in the raw source covered by description_span so
388+
// the file offset is accurate.
389+
let desc_file_start = tag.description_span.start.offset;
390+
let desc_in_doc_start = (desc_file_start - base_offset) as usize;
391+
let desc_in_doc_end =
392+
((tag.description_span.end.offset - base_offset) as usize).min(docblock.len());
393+
let raw_desc = &docblock[desc_in_doc_start..desc_in_doc_end];
394+
395+
if let Some(dollar_pos) = raw_desc.find('$') {
396+
let rest = &raw_desc[dollar_pos..];
397+
let name_end = rest[1..]
398+
.find(|c: char| !c.is_alphanumeric() && c != '_')
399+
.map(|i| i + 1)
400+
.unwrap_or(rest.len());
401+
if name_end > 1 {
402+
let name = rest[1..name_end].to_string();
403+
let file_offset = desc_file_start + dollar_pos as u32;
404+
results.push((name, file_offset));
405+
}
406+
}
407+
}
408+
409+
results
410+
}
411+
356412
// ─── Type span emission ─────────────────────────────────────────────────────
357413

358414
/// Emit `SymbolSpan` entries for a type token, splitting unions and

src/symbol_map/extraction.rs

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use mago_syntax::ast::*;
1111

1212
use super::docblock::{
1313
class_ref_span, class_ref_span_ctx, extract_docblock_symbols, extract_param_var_spans,
14-
get_docblock_text_with_offset, is_navigable_type,
14+
extract_var_docblock_var_spans, get_docblock_text_with_offset, is_navigable_type,
1515
};
1616
use super::{
1717
CallSite, ClassRefContext, SelfStaticParentKind, SymbolKind, SymbolMap, SymbolSpan,
@@ -298,7 +298,7 @@ fn extract_from_statement<'a>(
298298
extract_from_use_statement(use_stmt, &mut ctx.spans);
299299
}
300300
Statement::Expression(expr_stmt) => {
301-
extract_inline_docblock(expr_stmt, ctx);
301+
extract_inline_docblock(expr_stmt, ctx, scope_start);
302302
// Detect `assert($var instanceof ...)` and record its offset
303303
// as a sequential narrowing boundary for the diagnostic cache.
304304
if is_assert_instanceof(expr_stmt.expression) {
@@ -309,14 +309,14 @@ fn extract_from_statement<'a>(
309309
}
310310
Statement::Return(ret) => {
311311
emit_keyword(&ret.r#return, ctx);
312-
extract_inline_docblock(ret, ctx);
312+
extract_inline_docblock(ret, ctx, scope_start);
313313
if let Some(val) = ret.value {
314314
extract_from_expression(val, ctx, scope_start);
315315
}
316316
}
317317
Statement::Echo(echo) => {
318318
emit_keyword(&echo.echo, ctx);
319-
extract_inline_docblock(echo, ctx);
319+
extract_inline_docblock(echo, ctx, scope_start);
320320
for expr in echo.values.iter() {
321321
extract_from_expression(expr, ctx, scope_start);
322322
}
@@ -535,6 +535,18 @@ fn extract_from_statement<'a>(
535535
emit_keyword(&unset_stmt.unset, ctx);
536536
for val in unset_stmt.values.iter() {
537537
extract_from_expression(val, ctx, scope_start);
538+
if let Expression::Variable(Variable::Direct(dv)) = val {
539+
let name = dv.name.strip_prefix('$').unwrap_or(dv.name).to_string();
540+
ctx.var_defs.push(VarDefSite {
541+
offset: dv.span.start.offset,
542+
name,
543+
kind: VarDefKind::Unset,
544+
scope_start,
545+
effective_from: unset_stmt.span().end.offset,
546+
nesting_depth: ctx.cond_nesting_depth,
547+
block_end: ctx.cond_block_end_stack.last().copied().unwrap_or(u32::MAX),
548+
});
549+
}
538550
}
539551
}
540552
Statement::Constant(constant) => {
@@ -1346,11 +1358,30 @@ fn extract_from_method<'a>(method: &'a Method<'a>, ctx: &mut ExtractionCtx<'a>)
13461358
/// These comments are stored as trivia preceding the statement token.
13471359
/// Unlike class/method docblocks, inline `@var` annotations don't define
13481360
/// template parameters — we only care about the type spans they contain.
1349-
fn extract_inline_docblock(node: &impl HasSpan, ctx: &mut ExtractionCtx<'_>) {
1361+
fn extract_inline_docblock(node: &impl HasSpan, ctx: &mut ExtractionCtx<'_>, scope_start: u32) {
13501362
if let Some((doc_text, doc_offset)) =
13511363
get_docblock_text_with_offset(ctx.trivias, ctx.content, node)
13521364
{
13531365
let _tpl = extract_docblock_symbols(doc_text, doc_offset, &mut ctx.spans);
1366+
1367+
// Emit VarDefSite entries for `@var Type $varName` in inline docblocks.
1368+
for (name, file_offset) in extract_var_docblock_var_spans(doc_text, doc_offset) {
1369+
let name_len = name.len() as u32 + 1; // +1 for the `$` prefix
1370+
ctx.spans.push(SymbolSpan {
1371+
start: file_offset,
1372+
end: file_offset + name_len,
1373+
kind: SymbolKind::Variable { name: name.clone() },
1374+
});
1375+
ctx.var_defs.push(VarDefSite {
1376+
offset: file_offset,
1377+
name,
1378+
kind: VarDefKind::DocblockVar,
1379+
scope_start,
1380+
effective_from: file_offset,
1381+
nesting_depth: ctx.cond_nesting_depth,
1382+
block_end: ctx.cond_block_end_stack.last().copied().unwrap_or(u32::MAX),
1383+
});
1384+
}
13541385
}
13551386
}
13561387

src/symbol_map/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,13 @@ pub(crate) enum VarDefKind {
405405
/// the pre-body offset to the correct function body scope for
406406
/// rename and find-references.
407407
DocblockParam,
408+
/// A `@var Type $varName` mention in an inline docblock. Recorded
409+
/// so that the forward walker picks up the type annotation as a
410+
/// variable definition site.
411+
DocblockVar,
412+
/// An `unset($var)` call. Recorded so that variable completion can
413+
/// suppress the variable after the unset point.
414+
Unset,
408415
}
409416

410417
/// Per-file symbol location index.

tests/integration/completion_unset.rs

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -569,13 +569,9 @@ async fn test_unset_removes_variable_from_name_suggestions() {
569569
let items = complete_at(&backend, &uri, text, 6, 9).await;
570570
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
571571

572-
// Note: The SymbolMap-based variable collector does not track
573-
// unset() removal, so $user still appears in suggestions.
574-
// This is acceptable because unset() does not remove the variable
575-
// from PHP's scope — it just sets it to undefined.
576572
assert!(
577-
labels.contains(&"$user"),
578-
"$user should still be suggested (SymbolMap does not track unset), got: {:?}",
573+
!labels.contains(&"$user"),
574+
"$user should NOT be suggested after unset, got: {:?}",
579575
labels
580576
);
581577
assert!(
@@ -607,15 +603,14 @@ async fn test_unset_removes_multiple_variables_from_name_suggestions() {
607603
let items = complete_at(&backend, &uri, text, 7, 9).await;
608604
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
609605

610-
// SymbolMap does not track unset() removal.
611606
assert!(
612-
labels.contains(&"$alpha"),
613-
"$alpha should still be suggested (SymbolMap does not track unset), got: {:?}",
607+
!labels.contains(&"$alpha"),
608+
"$alpha should NOT be suggested after unset, got: {:?}",
614609
labels
615610
);
616611
assert!(
617-
labels.contains(&"$beta"),
618-
"$beta should still be suggested (SymbolMap does not track unset), got: {:?}",
612+
!labels.contains(&"$beta"),
613+
"$beta should NOT be suggested after unset, got: {:?}",
619614
labels
620615
);
621616
assert!(
@@ -670,10 +665,9 @@ async fn test_unset_removes_variable_top_level() {
670665
let items = complete_at(&backend, &uri, text, 4, 1).await;
671666
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
672667

673-
// SymbolMap does not track unset() removal.
674668
assert!(
675-
labels.contains(&"$user"),
676-
"$user should still be suggested (SymbolMap does not track unset), got: {:?}",
669+
!labels.contains(&"$user"),
670+
"$user should NOT be suggested after unset, got: {:?}",
677671
labels
678672
);
679673
assert!(

tests/integration/completion_variable_names.rs

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2109,12 +2109,9 @@ async fn test_var_docblock_variable_name_before_assignment() {
21092109
.map(|i| i.label.as_str())
21102110
.collect();
21112111

2112-
// Note: The SymbolMap-based variable collector does not currently
2113-
// emit VarDefSite entries for @var docblock variable names.
2114-
// The assignment `$x = ...` is the only VarDefSite here.
21152112
assert!(
2116-
!var_labels.contains(&"$adminUser"),
2117-
"$adminUser not yet supported via SymbolMap @var extraction. Got: {:?}",
2113+
var_labels.contains(&"$adminUser"),
2114+
"$adminUser should be suggested from @var docblock. Got: {:?}",
21182115
var_labels
21192116
);
21202117
}
@@ -2146,11 +2143,9 @@ async fn test_var_docblock_variable_name_in_method() {
21462143
.map(|i| i.label.as_str())
21472144
.collect();
21482145

2149-
// Note: The SymbolMap-based variable collector does not currently
2150-
// emit VarDefSite entries for @var docblock variable names.
21512146
assert!(
2152-
!var_labels.contains(&"$myUser"),
2153-
"$myUser not yet supported via SymbolMap @var extraction. Got: {:?}",
2147+
var_labels.contains(&"$myUser"),
2148+
"$myUser should be suggested from @var docblock. Got: {:?}",
21542149
var_labels
21552150
);
21562151
}
@@ -3539,10 +3534,9 @@ async fn test_completion_unset_removes_variable() {
35393534
"$keep should still be visible after unset of another var. Got: {:?}",
35403535
var_labels
35413536
);
3542-
// SymbolMap does not track unset() removal.
35433537
assert!(
3544-
var_labels.contains(&"$remove"),
3545-
"$remove should still be suggested (SymbolMap does not track unset). Got: {:?}",
3538+
!var_labels.contains(&"$remove"),
3539+
"$remove should NOT be suggested after unset. Got: {:?}",
35463540
var_labels
35473541
);
35483542
}

0 commit comments

Comments
 (0)