Skip to content

Commit 085b94c

Browse files
committed
Fix variadic parameter element type in foreach loops
1 parent 22db360 commit 085b94c

7 files changed

Lines changed: 274 additions & 33 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7575
- **Callable types inside unions displayed ambiguously.** `(Closure(int): string)|Foo` was formatted as `Closure(int): string|Foo`, which reads as a callable returning `string|Foo`. Callable types inside unions are now wrapped in parentheses.
7676
- **Hover and go-to-definition on attributes.** Attributes on properties, class constants, function/method parameters, and enum cases are now recognized by the symbol map. Previously only attributes on classes, methods, and top-level functions produced navigable symbols; hovering or Ctrl+Clicking an attribute on a property (e.g. `#[Assert\NotBlank]`) did nothing.
7777
- **Function-level `@template` with `array<TKey, TValue>` parameters.** Template substitution at call sites now correctly resolves generic wrapper names like `array`, `iterable`, and `list`. Previously, `collect($users)->first()->` failed to infer the element type because the wrapper name was incorrectly discarded during binding classification.
78+
- **Variadic parameter element type lost in `foreach`.** Iterating over a variadic parameter (e.g. `foreach ($placeholders as $value)` where the method declares `HtmlString|int|string ...$placeholders`) now resolves the loop variable to the element type. Previously the variable was untyped, breaking both completion and `instanceof` narrowing inside the loop body.
7879
- **Stack overflow when a foreach value variable shadows the iterator receiver.** Patterns like `foreach ($category->getBranch() as $category)` caused infinite recursion during type resolution because resolving the value variable re-entered the same foreach. The foreach resolver now detects this cycle at the AST level and skips the recursive path. A depth guard on `resolve_variable_types` provides a safety net for any remaining recursive patterns.
7980
- **PHPStan diagnostics hidden when a native diagnostic exists on the same line.** Full-line PHPStan diagnostics were suppressed whenever any precise native diagnostic appeared on the same line, even for completely unrelated issues. For example, `class.prefixed` was hidden because a native `unknown_class` diagnostic covered the same line. Deduplication now only suppresses a full-line diagnostic when the precise diagnostic on that line reports a related issue.
8081
- **Deprecated class in `implements` now renders with strikethrough.** Verified and tested that `DiagnosticTag::DEPRECATED` applies correctly for deprecated classes referenced in `implements` clauses, matching the existing coverage for `new`, type hints, and `extends`. Also verified that `$this`/`self`/`static` resolve to the correct class in files with multiple class declarations.

docs/todo.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ within the same impact tier.
2626
| 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 |
2727
| L11 | [`whereHas` closure parameter type from relation traversal](todo/laravel.md#l11-wherehas--wheredoesnthave-closure-parameter-type-from-relation-traversal) | Medium | Medium |
2828
| B7 | [PHPDoc `@param` generic array type not merged with native `array` hint](todo/bugs.md#b7-phpdoc-param-generic-array-type-not-merged-with-native-array-hint) | Low | Medium |
29-
| B8 | [Variadic parameter element type lost in `foreach`](todo/bugs.md#b8-variadic-parameter-element-type-lost-in-foreach) | Low | Low |
3029
| B9 | [Eloquent relationship property lookup is case-sensitive](todo/bugs.md#b9-eloquent-relationship-property-lookup-is-case-sensitive) | Low | Low |
3130
| H10 | [`return.unusedType` — remove unused type from return union](todo/phpstan-actions.md#h10-returnunusedtype--remove-unused-type-from-return-union) | Medium | Medium |
3231
| H6 | `return.type` — update return type to match actual returns | Medium | Medium |

docs/todo/bugs.md

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -70,38 +70,6 @@ native `array` with the docblock's `list<Request>`.
7070
**Impact:** 1 diagnostic in the shared project
7171
(`MobilePayConnection:76`).
7272

73-
## B8: Variadic parameter element type lost in `foreach`
74-
75-
When a method declares a variadic parameter with a union type like
76-
`HtmlString|int|string ...$placeholders`, iterating with
77-
`foreach ($placeholders as $value)` should give `$value` the element
78-
type `HtmlString|int|string`. Instead, the LSP resolves `$value` as
79-
untyped (hover returns nothing).
80-
81-
Real-world example — `ShortTexts.php`:
82-
83-
```php
84-
public static function get(int $id, Country $lang, HtmlString|int|string ...$placeholders): HtmlString|string
85-
{
86-
// ...
87-
foreach ($placeholders as $value) {
88-
$isHTMLValue = $value instanceof HtmlString;
89-
if ($isHTML) {
90-
$replace[] = $isHTMLValue ? $value->toHtml() : htmlentities((string)$value);
91-
// ^^^^^^ unresolved
92-
}
93-
}
94-
}
95-
```
96-
97-
The variadic `...$placeholders` is internally `array<int, HtmlString|int|string>`,
98-
but the LSP doesn't propagate the element type into the `foreach` loop
99-
variable. This is a prerequisite for the `instanceof` narrowing (which
100-
would further narrow `$value` to `HtmlString` in the truthy ternary
101-
branch), but the primary failure is the missing element type.
102-
103-
**Impact:** 1 diagnostic in the shared project (`ShortTexts:79`).
104-
10573
## B9: Eloquent relationship property lookup is case-sensitive
10674

10775
Laravel normalises property names via `Str::snake()` at runtime, so

example.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1284,6 +1284,30 @@ public function demo(): void
12841284
}
12851285

12861286

1287+
// ── Variadic Parameter Foreach ──────────────────────────────────────────────
1288+
1289+
class VariadicForeachDemo
1290+
{
1291+
public function demo(Pen ...$pens): void
1292+
{
1293+
// Variadic parameters are arrays: foreach extracts the element type
1294+
foreach ($pens as $pen) {
1295+
$pen->write(); // element type from variadic Pen ...$pens
1296+
}
1297+
}
1298+
1299+
public function unionVariadic(Pen|Pencil ...$tools): void
1300+
{
1301+
// Union variadic: foreach value is Pen|Pencil
1302+
foreach ($tools as $tool) {
1303+
if ($tool instanceof Pen) {
1304+
$tool->write(); // narrowed to Pen via instanceof
1305+
}
1306+
}
1307+
}
1308+
}
1309+
1310+
12871311
// ── Array Function Type Preservation ────────────────────────────────────────
12881312

12891313
class ArrayFuncDemo
@@ -5668,6 +5692,22 @@ function runDemoAssertions(): void
56685692
assert(CT_ALLOWED_HOSTS === ['localhost', '127.0.0.1'], 'CT_ALLOWED_HOSTS must match');
56695693
assert(CT_APP_VERSION === '2.0.0', 'CT_APP_VERSION must be "2.0.0"');
56705694

5695+
// ── Variadic foreach ────────────────────────────────────────────────
5696+
$vfDemo = new VariadicForeachDemo();
5697+
$vfPens = [new Pen('a'), new Pen('b')];
5698+
// demo() accepts Pen ...$pens — foreach inside should see Pen elements
5699+
$vfDemo->demo(...$vfPens);
5700+
foreach ($vfPens as $vfPen) {
5701+
assert($vfPen instanceof Pen, 'Variadic Pen element must be Pen');
5702+
}
5703+
$vfTools = [new Pen('x'), new Pencil()];
5704+
foreach ($vfTools as $vfTool) {
5705+
assert(
5706+
$vfTool instanceof Pen || $vfTool instanceof Pencil,
5707+
'Variadic union element must be Pen or Pencil'
5708+
);
5709+
}
5710+
56715711
echo "All assertions passed.\n";
56725712
}
56735713

src/completion/variable/closure_resolution.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1269,9 +1269,11 @@ fn resolve_closure_params_with_inferred(
12691269
results: &mut Vec<ResolvedType>,
12701270
inferred_types: &[String],
12711271
) {
1272+
let mut matched_param_is_variadic = false;
12721273
for (idx, param) in parameter_list.parameters.iter().enumerate() {
12731274
let pname = param.variable.name.to_string();
12741275
if pname == ctx.var_name {
1276+
matched_param_is_variadic = param.ellipsis.is_some();
12751277
// 1. Try the explicit type hint first.
12761278
if let Some(hint) = &param.hint {
12771279
let type_str = extract_hint_string(hint);
@@ -1390,6 +1392,22 @@ fn resolve_closure_params_with_inferred(
13901392
break;
13911393
}
13921394
}
1395+
1396+
// ── Variadic parameter wrapping ─────────────────────────────
1397+
// When the matched parameter is variadic (e.g.
1398+
// `HtmlString|int|string ...$placeholders`), the native type
1399+
// hint describes the *element* type, but the variable itself
1400+
// holds `list<ElementType>`. Wrap the resolved types so that
1401+
// foreach iteration can extract the element type via
1402+
// `PhpType::extract_value_type`.
1403+
if matched_param_is_variadic && !results.is_empty() {
1404+
for rt in results.iter_mut() {
1405+
rt.type_string = PhpType::Generic("list".to_string(), vec![rt.type_string.clone()]);
1406+
// The variable is now an array, not a class instance,
1407+
// so clear the class_info.
1408+
rt.class_info = None;
1409+
}
1410+
}
13931411
}
13941412

13951413
/// Check whether the inferred callable-signature type is a more specific

src/completion/variable/resolution.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -744,9 +744,11 @@ fn resolve_variable_in_members<'b>(
744744
// We no longer return early here so that the method body
745745
// can be scanned for instanceof narrowing / reassignments.
746746
let mut param_results: Vec<ResolvedType> = Vec::new();
747+
let mut matched_param_is_variadic = false;
747748
for param in method.parameter_list.parameters.iter() {
748749
let pname = param.variable.name.to_string();
749750
if pname == ctx.var_name {
751+
matched_param_is_variadic = param.ellipsis.is_some();
750752
// Try the native AST type hint first.
751753
let native_type_str = param.hint.as_ref().map(|h| extract_hint_string(h));
752754

@@ -927,6 +929,23 @@ fn resolve_variable_in_members<'b>(
927929
}
928930
}
929931

932+
// ── Variadic parameter wrapping ─────────────────────────
933+
// When the matched parameter is variadic (e.g.
934+
// `HtmlString|int|string ...$placeholders`), the native
935+
// type hint describes the *element* type, but the variable
936+
// itself holds `list<ElementType>`. Wrap the resolved
937+
// types so that foreach iteration can extract the element
938+
// type via `PhpType::extract_value_type`.
939+
if matched_param_is_variadic && !param_results.is_empty() {
940+
for rt in &mut param_results {
941+
rt.type_string =
942+
PhpType::Generic("list".to_string(), vec![rt.type_string.clone()]);
943+
// The variable is now an array, not a class instance,
944+
// so clear the class_info.
945+
rt.class_info = None;
946+
}
947+
}
948+
930949
if let MethodBody::Concrete(block) = &method.body {
931950
let blk_start = block.left_brace.start.offset;
932951
let blk_end = block.right_brace.end.offset;

tests/integration/completion_variables.rs

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20283,3 +20283,199 @@ async fn test_completion_magic_call_static_fallback() {
2028320283
_ => panic!("Expected CompletionResponse::Array"),
2028420284
}
2028520285
}
20286+
20287+
/// Foreach over a variadic parameter with a class type resolves the
20288+
/// loop variable to the element type.
20289+
#[tokio::test]
20290+
async fn test_completion_foreach_variadic_param_class_type() {
20291+
let backend = create_test_backend();
20292+
20293+
let uri = Url::parse("file:///variadic_foreach.php").unwrap();
20294+
let text = concat!(
20295+
"<?php\n",
20296+
"class HtmlString {\n",
20297+
" public function toHtml(): string { return ''; }\n",
20298+
"}\n",
20299+
"class Formatter {\n",
20300+
" public function format(HtmlString ...$items): void {\n",
20301+
" foreach ($items as $value) {\n",
20302+
" $value->\n",
20303+
" }\n",
20304+
" }\n",
20305+
"}\n",
20306+
);
20307+
20308+
let open_params = DidOpenTextDocumentParams {
20309+
text_document: TextDocumentItem {
20310+
uri: uri.clone(),
20311+
language_id: "php".to_string(),
20312+
version: 1,
20313+
text: text.to_string(),
20314+
},
20315+
};
20316+
backend.did_open(open_params).await;
20317+
20318+
let completion_params = CompletionParams {
20319+
text_document_position: TextDocumentPositionParams {
20320+
text_document: TextDocumentIdentifier { uri },
20321+
position: Position {
20322+
line: 7,
20323+
character: 20,
20324+
},
20325+
},
20326+
work_done_progress_params: WorkDoneProgressParams::default(),
20327+
partial_result_params: PartialResultParams::default(),
20328+
context: None,
20329+
};
20330+
20331+
let result = backend.completion(completion_params).await.unwrap();
20332+
assert!(
20333+
result.is_some(),
20334+
"Completion should return results for foreach value from variadic param"
20335+
);
20336+
20337+
match result.unwrap() {
20338+
CompletionResponse::Array(items) => {
20339+
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
20340+
assert!(
20341+
labels.iter().any(|l| l.starts_with("toHtml")),
20342+
"Should include toHtml method from HtmlString, got: {:?}",
20343+
labels
20344+
);
20345+
}
20346+
_ => panic!("Expected CompletionResponse::Array"),
20347+
}
20348+
}
20349+
20350+
/// Foreach over a variadic parameter with a union type containing a
20351+
/// class resolves the loop variable so that instanceof narrowing can
20352+
/// work on it.
20353+
#[tokio::test]
20354+
async fn test_completion_foreach_variadic_param_union_type() {
20355+
let backend = create_test_backend();
20356+
20357+
let uri = Url::parse("file:///variadic_union_foreach.php").unwrap();
20358+
let text = concat!(
20359+
"<?php\n",
20360+
"class HtmlString {\n",
20361+
" public function toHtml(): string { return ''; }\n",
20362+
"}\n",
20363+
"class ShortTexts {\n",
20364+
" public static function get(int $id, HtmlString|int|string ...$placeholders): void {\n",
20365+
" foreach ($placeholders as $value) {\n",
20366+
" if ($value instanceof HtmlString) {\n",
20367+
" $value->\n",
20368+
" }\n",
20369+
" }\n",
20370+
" }\n",
20371+
"}\n",
20372+
);
20373+
20374+
let open_params = DidOpenTextDocumentParams {
20375+
text_document: TextDocumentItem {
20376+
uri: uri.clone(),
20377+
language_id: "php".to_string(),
20378+
version: 1,
20379+
text: text.to_string(),
20380+
},
20381+
};
20382+
backend.did_open(open_params).await;
20383+
20384+
let completion_params = CompletionParams {
20385+
text_document_position: TextDocumentPositionParams {
20386+
text_document: TextDocumentIdentifier { uri },
20387+
position: Position {
20388+
line: 8,
20389+
character: 24,
20390+
},
20391+
},
20392+
work_done_progress_params: WorkDoneProgressParams::default(),
20393+
partial_result_params: PartialResultParams::default(),
20394+
context: None,
20395+
};
20396+
20397+
let result = backend.completion(completion_params).await.unwrap();
20398+
assert!(
20399+
result.is_some(),
20400+
"Completion should return results after instanceof narrowing on variadic foreach value"
20401+
);
20402+
20403+
match result.unwrap() {
20404+
CompletionResponse::Array(items) => {
20405+
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
20406+
assert!(
20407+
labels.iter().any(|l| l.starts_with("toHtml")),
20408+
"Should include toHtml from HtmlString after instanceof narrowing, got: {:?}",
20409+
labels
20410+
);
20411+
}
20412+
_ => panic!("Expected CompletionResponse::Array"),
20413+
}
20414+
}
20415+
20416+
/// Foreach over a variadic parameter in a standalone function (not a
20417+
/// method) resolves the loop variable to the element type.
20418+
#[tokio::test]
20419+
async fn test_completion_foreach_variadic_param_standalone_function() {
20420+
let backend = create_test_backend();
20421+
20422+
let uri = Url::parse("file:///variadic_func_foreach.php").unwrap();
20423+
let text = concat!(
20424+
"<?php\n",
20425+
"class Widget {\n",
20426+
" public string $name;\n",
20427+
" public function render(): string { return ''; }\n",
20428+
"}\n",
20429+
"function processWidgets(Widget ...$widgets): void {\n",
20430+
" foreach ($widgets as $w) {\n",
20431+
" $w->\n",
20432+
" }\n",
20433+
"}\n",
20434+
);
20435+
20436+
let open_params = DidOpenTextDocumentParams {
20437+
text_document: TextDocumentItem {
20438+
uri: uri.clone(),
20439+
language_id: "php".to_string(),
20440+
version: 1,
20441+
text: text.to_string(),
20442+
},
20443+
};
20444+
backend.did_open(open_params).await;
20445+
20446+
let completion_params = CompletionParams {
20447+
text_document_position: TextDocumentPositionParams {
20448+
text_document: TextDocumentIdentifier { uri },
20449+
position: Position {
20450+
line: 7,
20451+
character: 12,
20452+
},
20453+
},
20454+
work_done_progress_params: WorkDoneProgressParams::default(),
20455+
partial_result_params: PartialResultParams::default(),
20456+
context: None,
20457+
};
20458+
20459+
let result = backend.completion(completion_params).await.unwrap();
20460+
assert!(
20461+
result.is_some(),
20462+
"Completion should return results for foreach value from variadic function param"
20463+
);
20464+
20465+
match result.unwrap() {
20466+
CompletionResponse::Array(items) => {
20467+
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
20468+
assert!(
20469+
labels.iter().any(|l| l.starts_with("name")),
20470+
"Should include name property from Widget, got: {:?}",
20471+
labels
20472+
);
20473+
assert!(
20474+
labels.iter().any(|l| l.starts_with("render")),
20475+
"Should include render method from Widget, got: {:?}",
20476+
labels
20477+
);
20478+
}
20479+
_ => panic!("Expected CompletionResponse::Array"),
20480+
}
20481+
}

0 commit comments

Comments
 (0)