Skip to content

Commit 321727e

Browse files
committed
Add Blade foreach variable resolution for standalone @var docblocks
1 parent 4db156b commit 321727e

7 files changed

Lines changed: 654 additions & 110 deletions

File tree

docs/CHANGELOG.md

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

1818
- **Blade hover positions.** Hover ranges in `.blade.php` files are now translated back to original Blade coordinates, so the editor highlights the correct symbol under the cursor.
1919
- **Standalone `@var` completion.** Variables typed only via a standalone `/** @var Type $var */` docblock (with no preceding assignment) now resolve for member completion and go-to-definition. This fixes completion inside Blade templates that use `@php /** @var \App\Models\Foo $var */ @endphp`.
20+
- **Foreach loop variable resolution in Blade.** Loop variables (e.g. `$user` in `@foreach($users->active() as $user)`) now resolve their element type correctly when the iterable is typed via a standalone `@var` docblock. Template parameter bounds (e.g. `@template TModel of BlogAuthor`) are substituted through the inheritance chain.
2021

2122
### Changed
2223

docs/todo/bugs.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ pipeline so it produces correct data. Downstream consumers
88
to second-guess upstream output.
99

1010

11+
## B17. Blade `{{` hover shows inner expression instead of `e()`
12+
13+
Hovering on the `{{` delimiter in a Blade template should show
14+
hover info for the implicit `e()` (htmlspecialchars) call that
15+
Blade compiles to. Currently it shows hover for the expression
16+
inside the echo (e.g. `config(...)`) because the position mapping
17+
offsets into the virtual PHP content rather than recognising the
18+
delimiter itself.
19+
20+
1121
## B16. PDOStatement fetch mode-dependent return types
1222

1323
**Blocked on:** [phpstorm-stubs#1882](https://github.com/JetBrains/phpstorm-stubs/pull/1882)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{{-- Demonstrates variable completion inside included partials --}}
2+
@php
3+
/**
4+
* @bladestan-signature
5+
* @var ?\App\Models\BlogPost $post
6+
*/
7+
@endphp
8+
9+
<div style="font-family: sans-serif; padding: 20px;">
10+
<h2>{{ __('messages.welcome') }}</h2>
11+
12+
@if($post)
13+
<p>{{ $post->getTitle() }} has been published!</p>
14+
<p>Published: {{ $post->created_at->diffForHumans() }}</p>
15+
@endif
16+
</div>

examples/laravel/resources/views/welcome.blade.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
@if($user)
2020
<p>Hello, {{ $user->name }}!</p>
2121
<p>Email: {{ $user->email }}</p>
22-
<p>Status: {{ $user->status->label() }}</p>
2322
@endif
2423

2524
{{-- Foreach with model completion — $posts->byNewest() uses PostCollection --}}
@@ -38,7 +37,7 @@
3837
</nav>
3938

4039
{{-- Includes and nested views --}}
41-
@include('emails.order_shipped', ['order' => $order ?? null])
40+
@include('emails.order_shipped', ['post' => $posts->first()])
4241

4342
{{-- Conditional rendering with config --}}
4443
@if(config('app.debug'))

src/blade/preprocessor.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,25 @@ mod tests {
377377
);
378378
}
379379

380+
#[test]
381+
fn test_preprocess_foreach() {
382+
let content = r#"@php
383+
/**
384+
* @var \App\Models\AuthorCollection $users
385+
*/
386+
@endphp
387+
388+
@foreach($users->active()->byName() as $user)
389+
<p>{{ $user->name }}</p>
390+
@endforeach
391+
"#;
392+
let (php, _) = preprocess(content);
393+
for (i, line) in php.lines().enumerate() {
394+
eprintln!("{:2}: {}", i, line);
395+
}
396+
assert!(php.contains("$user->name"));
397+
}
398+
380399
#[test]
381400
fn test_preprocess_multiline_directive() {
382401
let content = "@include('vendor.fbRemarket', [\n 'facebook_pixel_id' => Config::get('services.facebook.pixel_id'),\n])\n\n@include('vendor.googleRemarket')";

src/completion/variable/forward_walk.rs

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6115,6 +6115,55 @@ fn process_foreach<'b>(foreach: &'b Foreach<'b>, scope: &mut ScopeState, ctx: &F
61156115
return;
61166116
}
61176117

6118+
// Apply any standalone `/** @var Type $var */` docblocks that precede
6119+
// the foreach keyword. These are not separate AST statements (the
6120+
// parser attaches them as comments to the foreach), so they won't be
6121+
// processed by `process_expression_statement`. Without this, variables
6122+
// typed only via docblock (common in Blade templates) won't be in scope
6123+
// when the iterable expression is resolved.
6124+
//
6125+
// We extract all variables referenced in the foreach expression and
6126+
// check for @var annotations for each one.
6127+
let foreach_offset = foreach.foreach.span().start.offset as usize;
6128+
if let Expression::Variable(Variable::Direct(dv)) = foreach.expression {
6129+
let var_name = format!("${}", dv.name);
6130+
if scope.get(dv.name).is_empty()
6131+
&& let Some(var_type) =
6132+
crate::docblock::find_var_raw_type_in_source(ctx.content, foreach_offset, &var_name)
6133+
{
6134+
let resolved = resolve_type_to_resolved_types(
6135+
&crate::util::resolve_php_type_names(&var_type, ctx.class_loader),
6136+
ctx,
6137+
);
6138+
scope.set(dv.name, resolved);
6139+
}
6140+
} else {
6141+
// For complex expressions like `$users->active()->byName()`,
6142+
// extract the base variable and resolve its type from @var.
6143+
let expr_start = foreach.expression.span().start.offset as usize;
6144+
let expr_end = foreach.expression.span().end.offset as usize;
6145+
if let Some(expr_text) = ctx.content.get(expr_start..expr_end) {
6146+
// Extract the base variable (e.g. "$users" from "$users->active()->byName()")
6147+
if let Some(base_end) = expr_text.find("->").or_else(|| expr_text.find("::")) {
6148+
let base_var = expr_text[..base_end].trim();
6149+
if let Some(scope_key) = base_var.strip_prefix('$')
6150+
&& scope.get(scope_key).is_empty()
6151+
&& let Some(var_type) = crate::docblock::find_var_raw_type_in_source(
6152+
ctx.content,
6153+
foreach_offset,
6154+
base_var,
6155+
)
6156+
{
6157+
let resolved = resolve_type_to_resolved_types(
6158+
&crate::util::resolve_php_type_names(&var_type, ctx.class_loader),
6159+
ctx,
6160+
);
6161+
scope.set(scope_key, resolved);
6162+
}
6163+
}
6164+
}
6165+
}
6166+
61186167
// Resolve the iterable expression's type.
61196168
let iter_type = resolve_foreach_iterable_type(foreach, scope, ctx);
61206169

@@ -6514,13 +6563,6 @@ fn bind_foreach_value<'b>(
65146563
}
65156564

65166565
// Strategy 2: class-based fallback for bare collection names.
6517-
// Resolve the iterable type to ClassInfo, merge inheritance,
6518-
// and extract the element type from @extends/@implements generics.
6519-
// Skip when the extracted element type is an unsubstituted
6520-
// template parameter (e.g. `TValue` from `ArrayObject<TKey, TValue>`)
6521-
// — binding it would produce incorrect scope entries that
6522-
// override the backward scanner's correct resolution via
6523-
// inline `/** @var */` docblocks.
65246566
let element_via_class = resolve_iterable_element_via_class(it, ctx);
65256567
if let Some(element_type) = element_via_class
65266568
&& !is_unsubstituted_template_param(&element_type)
@@ -6802,7 +6844,19 @@ fn resolve_iterable_element_via_class(
68026844
&merged,
68036845
ctx.class_loader,
68046846
);
6805-
if element_type.is_some() {
6847+
if let Some(ref et) = element_type {
6848+
// When the extracted type is an unsubstituted template parameter
6849+
// (e.g. `TModel`), resolve it through the class's template bounds
6850+
// (e.g. `@template TModel of BlogAuthor` → `BlogAuthor`).
6851+
if let Some(name) = et.base_name()
6852+
&& merged
6853+
.template_params
6854+
.iter()
6855+
.any(|p| p.as_ref() as &str == name)
6856+
&& let Some(bound) = merged.template_param_bounds.get(&crate::atom::atom(name))
6857+
{
6858+
return Some(bound.clone());
6859+
}
68066860
return element_type;
68076861
}
68086862
}

0 commit comments

Comments
 (0)