Skip to content

Commit 9888310

Browse files
committed
Support foreach over arrays with array shape element types
1 parent caa6163 commit 9888310

4 files changed

Lines changed: 123 additions & 5 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
### Added
1111

12+
- **Foreach over arrays with non-class element types.** When iterating over a generic array whose value type is an array shape (e.g. `array<int, array{tool: Pen, count: int}>`), the foreach iteration variable now carries the full shape type. Bracket access on the iteration variable (`$entry['tool']->`) resolves each key to its declared type. Previously the element type was silently dropped when it was not a class name, leaving the iteration variable untyped.
1213
- **Array value type tracking from variable-key assignments.** When an array is built incrementally with variable keys inside a loop (`$arr[$key] = $value`), the element type is now tracked. Subsequent `foreach` iteration, bracket access, and null-coalescing (`$arr[$key] ?? null`) all resolve the correct element type instead of falling back to bare `array`.
1314
- **`Conditionable::when()` / `unless()` chain resolution.** Chained calls after `when()` or `unless()` on Eloquent Builder, Collection, and any class using the `Conditionable` trait now resolve correctly. Previously the unresolved `TWhenReturnType` template parameter in the return type broke chain continuation, so `Builder::where(...)->when(...)->get()` lost type information after `when()`.
1415
- **`whereHas` / `whereDoesntHave` closure parameter from relation traversal.** The closure parameter in `whereHas`, `orWhereHas`, `whereDoesntHave`, `orWhereDoesntHave`, `withWhereHas`, and related methods is now typed as `Builder<RelatedModel>` instead of `Builder<CallingModel>`. The relation name string (first argument) is resolved by walking the model's relationship methods. Dot-notation chains like `whereHas('category.articles', fn($q) => ...)` follow each segment to the final related model. Works for static calls (`Brand::whereHas(...)`), builder instance calls (`$query->whereHas(...)`), and body-inferred relationships (no `@return` annotation). Falls back to the calling model's builder when the relation cannot be resolved.

example.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1389,6 +1389,25 @@ public function demo(): void
13891389
}
13901390

13911391

1392+
// ── Foreach Array Shape Elements ────────────────────────────────────────────
1393+
1394+
class ForeachArrayShapeDemo
1395+
{
1396+
/**
1397+
* @param array<int, array{tool: Pen, count: int}> $inventory
1398+
*/
1399+
public function demo(array $inventory): void
1400+
{
1401+
// When iterating over an array whose value type is an array shape,
1402+
// the foreach variable carries the shape type so that bracket
1403+
// access resolves each key to its declared type.
1404+
foreach ($inventory as $entry) {
1405+
$entry['tool']->write(); // array{tool: Pen, count: int} → Pen
1406+
}
1407+
}
1408+
}
1409+
1410+
13921411
// ── Variadic Parameter Foreach ──────────────────────────────────────────────
13931412

13941413
class VariadicForeachDemo
@@ -5920,6 +5939,13 @@ function runDemoAssertions(): void
59205939
assert($tgMixed instanceof Pen, 'Else branch of is_array() must be Pen');
59215940
}
59225941

5942+
// ── Foreach array shape elements ────────────────────────────────────
5943+
/** @var array<int, array{tool: Pen, count: int}> $fasInventory */
5944+
$fasInventory = [['tool' => new Pen('red'), 'count' => 3]];
5945+
foreach ($fasInventory as $fasEntry) {
5946+
assert($fasEntry['tool'] instanceof Pen, 'Foreach over array shape must resolve key type');
5947+
}
5948+
59235949
// ── Loop array build (variable-key assignment) ──────────────────────
59245950
$labPens = [new Pen('red'), new Pen('blue')];
59255951
$labIndexed = [];

src/completion/variable/foreach_resolution.rs

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,13 @@ pub(in crate::completion) fn try_resolve_foreach_value_type<'b>(
249249
// Extract the generic element type (e.g. `list<User>` → `User`).
250250
if let Some(ref rt) = raw_type {
251251
let parsed = crate::php_type::PhpType::parse(rt);
252-
if let Some(element_type) = parsed.extract_value_type(true) {
252+
// Use `extract_value_type(false)` to include non-class element
253+
// types such as array shapes (`array{key: Type}`), scalars, and
254+
// generic arrays. The foreach value variable may be used with
255+
// bracket access (`$item['key']->`) or other operations where
256+
// a non-class type is meaningful. `push_foreach_resolved_types_typed`
257+
// handles both class and non-class element types.
258+
if let Some(element_type) = parsed.extract_value_type(false) {
253259
push_foreach_resolved_types_typed(element_type, ctx, results, conditional);
254260
return;
255261
}
@@ -475,11 +481,21 @@ fn push_foreach_resolved_types_typed(
475481
ctx.class_loader,
476482
);
477483

478-
if resolved.is_empty() {
479-
return;
480-
}
484+
let resolved_types = if resolved.is_empty() {
485+
// The element type is not a class (e.g. an array shape like
486+
// `array{bundle: Product, count: int}`, a scalar, or a
487+
// generic array). Push a type-only ResolvedType so that
488+
// downstream consumers (array bracket access, hover, etc.)
489+
// can still see the structured type string and resolve
490+
// through it.
491+
if matches!(ty, PhpType::Named(n) if n == "mixed") {
492+
return;
493+
}
494+
vec![ResolvedType::from_type_string(ty.clone())]
495+
} else {
496+
ResolvedType::from_classes_with_hint(resolved, ty.clone())
497+
};
481498

482-
let resolved_types = ResolvedType::from_classes_with_hint(resolved, ty.clone());
483499
if !conditional {
484500
results.clear();
485501
}

tests/integration/completion_variables.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20722,3 +20722,78 @@ async fn test_completion_foreach_variadic_param_standalone_function() {
2072220722
_ => panic!("Expected CompletionResponse::Array"),
2072320723
}
2072420724
}
20725+
20726+
// ─── Foreach variable type from @param array shape ──────────────────────────
20727+
20728+
/// When a parameter is annotated with `@param array<int, array{bundle: Product, count: int}>`,
20729+
/// iterating over it with foreach should propagate the value type so that
20730+
/// `$entry['bundle']->` resolves to `Product`.
20731+
#[tokio::test]
20732+
async fn test_foreach_variable_type_from_param_array_shape() {
20733+
let backend = create_test_backend();
20734+
20735+
let uri = Url::parse("file:///foreach_param_shape.php").unwrap();
20736+
let text = concat!(
20737+
"<?php\n", // 0
20738+
"class Product {\n", // 1
20739+
" public string $sku;\n", // 2
20740+
" public function parentProduct(): Product { return new self(); }\n", // 3
20741+
"}\n", // 4
20742+
"\n", // 5
20743+
"class Listener {\n", // 6
20744+
" /** @param array<int, array{bundle: Product, count: int}> $counts */\n", // 7
20745+
" public function handle(array $counts): void {\n", // 8
20746+
" foreach ($counts as $entry) {\n", // 9
20747+
" $entry['bundle']->\n", // 10
20748+
" }\n", // 11
20749+
" }\n", // 12
20750+
"}\n", // 13
20751+
);
20752+
20753+
let open_params = DidOpenTextDocumentParams {
20754+
text_document: TextDocumentItem {
20755+
uri: uri.clone(),
20756+
language_id: "php".to_string(),
20757+
version: 1,
20758+
text: text.to_string(),
20759+
},
20760+
};
20761+
backend.did_open(open_params).await;
20762+
20763+
// Cursor at line 10 after `$entry['bundle']->`
20764+
let completion_params = CompletionParams {
20765+
text_document_position: TextDocumentPositionParams {
20766+
text_document: TextDocumentIdentifier { uri },
20767+
position: Position {
20768+
line: 10,
20769+
character: 34,
20770+
},
20771+
},
20772+
work_done_progress_params: WorkDoneProgressParams::default(),
20773+
partial_result_params: PartialResultParams::default(),
20774+
context: None,
20775+
};
20776+
20777+
let result = backend.completion(completion_params).await.unwrap();
20778+
assert!(
20779+
result.is_some(),
20780+
"Completion should return results for $entry['bundle']-> inside foreach over array shape param"
20781+
);
20782+
20783+
match result.unwrap() {
20784+
CompletionResponse::Array(items) => {
20785+
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
20786+
assert!(
20787+
labels.iter().any(|l| l.starts_with("sku")),
20788+
"Should include 'sku' from Product via array shape foreach. Got: {:?}",
20789+
labels
20790+
);
20791+
assert!(
20792+
labels.iter().any(|l| l.starts_with("parentProduct")),
20793+
"Should include 'parentProduct' from Product via array shape foreach. Got: {:?}",
20794+
labels
20795+
);
20796+
}
20797+
_ => panic!("Expected CompletionResponse::Array"),
20798+
}
20799+
}

0 commit comments

Comments
 (0)