Skip to content

Commit 43a697d

Browse files
committed
Pretend like PHP varialbes do not leak out of loops scopes
1 parent e7ebe94 commit 43a697d

3 files changed

Lines changed: 163 additions & 32 deletions

File tree

src/completion/variable_completion.rs

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -371,16 +371,21 @@ fn collect_from_statements<'b>(
371371
}
372372
},
373373
Statement::Foreach(foreach) => {
374-
// Collect the key and value variables — they are defined
375-
// in the foreach header which starts before the cursor
376-
// (guaranteed by the span check above).
377-
if let Some(key_expr) = foreach.target.key() {
378-
collect_var_name_from_expression(key_expr, vars);
379-
}
380-
collect_var_name_from_expression(foreach.target.value(), vars);
381-
// Recurse into body
382-
for inner in foreach.body.statements() {
383-
collect_from_statement(inner, cursor_offset, vars);
374+
// Only collect the key/value variables when the cursor is
375+
// inside the foreach body. Outside the loop these
376+
// iteration variables should not be in scope.
377+
let body_span = foreach.body.span();
378+
if cursor_offset >= body_span.start.offset
379+
&& cursor_offset <= body_span.end.offset
380+
{
381+
if let Some(key_expr) = foreach.target.key() {
382+
collect_var_name_from_expression(key_expr, vars);
383+
}
384+
collect_var_name_from_expression(foreach.target.value(), vars);
385+
// Recurse into body
386+
for inner in foreach.body.statements() {
387+
collect_from_statement(inner, cursor_offset, vars);
388+
}
384389
}
385390
}
386391
Statement::For(for_stmt) => {

src/completion/variable_resolution.rs

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -476,29 +476,40 @@ impl Backend {
476476
}
477477
},
478478
Statement::Foreach(foreach) => {
479-
// ── Foreach value type from generic iterables ────
480-
// When the variable we're resolving is the foreach
481-
// *value* variable, try to infer its type from the
482-
// iterated expression's generic type annotation.
483-
//
484-
// Example:
485-
// /** @var list<User> $users */
486-
// foreach ($users as $user) { $user-> }
487-
//
488-
// Here `$user` is resolved to `User`.
489-
Self::try_resolve_foreach_value_type(foreach, ctx, results, conditional);
479+
// Only resolve the foreach value variable and recurse
480+
// into the body when the cursor is actually inside it.
481+
// Outside the loop the iteration variables are out of
482+
// scope.
483+
let body_span = foreach.body.span();
484+
if ctx.cursor_offset >= body_span.start.offset
485+
&& ctx.cursor_offset <= body_span.end.offset
486+
{
487+
// ── Foreach value type from generic iterables ──
488+
// When the variable we're resolving is the foreach
489+
// *value* variable, try to infer its type from the
490+
// iterated expression's generic type annotation.
491+
//
492+
// Example:
493+
// /** @var list<User> $users */
494+
// foreach ($users as $user) { $user-> }
495+
//
496+
// Here `$user` is resolved to `User`.
497+
Self::try_resolve_foreach_value_type(foreach, ctx, results, conditional);
490498

491-
match &foreach.body {
492-
ForeachBody::Statement(inner) => {
493-
Self::check_statement_for_assignments(inner, ctx, results, true);
494-
}
495-
ForeachBody::ColonDelimited(body) => {
496-
Self::walk_statements_for_assignments(
497-
body.statements.iter(),
498-
ctx,
499-
results,
500-
true,
501-
);
499+
match &foreach.body {
500+
ForeachBody::Statement(inner) => {
501+
Self::check_statement_for_assignments(
502+
inner, ctx, results, true,
503+
);
504+
}
505+
ForeachBody::ColonDelimited(body) => {
506+
Self::walk_statements_for_assignments(
507+
body.statements.iter(),
508+
ctx,
509+
results,
510+
true,
511+
);
512+
}
502513
}
503514
}
504515
}

tests/completion_variable_names.rs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1297,6 +1297,121 @@ async fn test_completion_variable_used_in_different_contexts() {
12971297
}
12981298

12991299
/// Superglobals should not be duplicated even if they also appear in file content.
1300+
#[tokio::test]
1301+
async fn test_completion_foreach_variable_not_visible_after_loop() {
1302+
let backend = create_test_backend();
1303+
1304+
let uri = Url::parse("file:///var_foreach_scope.php").unwrap();
1305+
let text = concat!(
1306+
"<?php\n",
1307+
"$items = [1, 2, 3];\n",
1308+
"foreach ($items as $key => $value) {\n",
1309+
" echo $value;\n",
1310+
"}\n",
1311+
"$\n",
1312+
);
1313+
1314+
// Cursor is after the foreach on line 5 — `$value` and `$key` should
1315+
// NOT appear because they are scoped to the loop body.
1316+
let items = complete_at(&backend, &uri, text, 5, 1).await;
1317+
1318+
let var_labels: Vec<&str> = items
1319+
.iter()
1320+
.filter(|i| i.kind == Some(CompletionItemKind::VARIABLE))
1321+
.map(|i| i.label.as_str())
1322+
.collect();
1323+
1324+
assert!(
1325+
!var_labels.contains(&"$value"),
1326+
"$value should NOT be visible after the foreach loop. Got: {:?}",
1327+
var_labels
1328+
);
1329+
assert!(
1330+
!var_labels.contains(&"$key"),
1331+
"$key should NOT be visible after the foreach loop. Got: {:?}",
1332+
var_labels
1333+
);
1334+
assert!(
1335+
var_labels.contains(&"$items"),
1336+
"$items should still be visible after the foreach loop. Got: {:?}",
1337+
var_labels
1338+
);
1339+
}
1340+
1341+
#[tokio::test]
1342+
async fn test_completion_foreach_variable_not_visible_before_loop() {
1343+
let backend = create_test_backend();
1344+
1345+
let uri = Url::parse("file:///var_foreach_before.php").unwrap();
1346+
let text = concat!(
1347+
"<?php\n",
1348+
"$items = [1, 2, 3];\n",
1349+
"$\n",
1350+
"foreach ($items as $value) {\n",
1351+
" echo $value;\n",
1352+
"}\n",
1353+
);
1354+
1355+
// Cursor is before the foreach on line 2
1356+
let items = complete_at(&backend, &uri, text, 2, 1).await;
1357+
1358+
let var_labels: Vec<&str> = items
1359+
.iter()
1360+
.filter(|i| i.kind == Some(CompletionItemKind::VARIABLE))
1361+
.map(|i| i.label.as_str())
1362+
.collect();
1363+
1364+
assert!(
1365+
!var_labels.contains(&"$value"),
1366+
"$value should NOT be visible before the foreach loop. Got: {:?}",
1367+
var_labels
1368+
);
1369+
assert!(
1370+
var_labels.contains(&"$items"),
1371+
"$items should be visible before the foreach loop. Got: {:?}",
1372+
var_labels
1373+
);
1374+
}
1375+
1376+
#[tokio::test]
1377+
async fn test_completion_foreach_variable_visible_inside_loop() {
1378+
let backend = create_test_backend();
1379+
1380+
let uri = Url::parse("file:///var_foreach_inside.php").unwrap();
1381+
let text = concat!(
1382+
"<?php\n",
1383+
"$items = [1, 2, 3];\n",
1384+
"foreach ($items as $key => $value) {\n",
1385+
" $\n",
1386+
"}\n",
1387+
);
1388+
1389+
// Cursor is inside the foreach on line 3
1390+
let items = complete_at(&backend, &uri, text, 3, 5).await;
1391+
1392+
let var_labels: Vec<&str> = items
1393+
.iter()
1394+
.filter(|i| i.kind == Some(CompletionItemKind::VARIABLE))
1395+
.map(|i| i.label.as_str())
1396+
.collect();
1397+
1398+
assert!(
1399+
var_labels.contains(&"$value"),
1400+
"$value should be visible inside the foreach loop. Got: {:?}",
1401+
var_labels
1402+
);
1403+
assert!(
1404+
var_labels.contains(&"$key"),
1405+
"$key should be visible inside the foreach loop. Got: {:?}",
1406+
var_labels
1407+
);
1408+
assert!(
1409+
var_labels.contains(&"$items"),
1410+
"$items should be visible inside the foreach loop. Got: {:?}",
1411+
var_labels
1412+
);
1413+
}
1414+
13001415
#[tokio::test]
13011416
async fn test_completion_superglobal_not_duplicated() {
13021417
let backend = create_test_backend();

0 commit comments

Comments
 (0)