Skip to content

Commit bafba5a

Browse files
committed
Add Blade diagnostics and improve template intelligence
1 parent 807e33d commit bafba5a

9 files changed

Lines changed: 102 additions & 16 deletions

File tree

docs/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5252

5353
### Fixed
5454

55+
- **Blade diagnostics in `analyze` command.** Blade template files now receive full diagnostics (unknown members, unknown classes, unused variables, etc.) when running `phpantom_lsp analyze`. Previously, the analyzer passed raw Blade template content to diagnostic collectors instead of the preprocessed virtual PHP, so no diagnostics were produced.
56+
- **Blade unknown-member diagnostic positions.** Unknown-member diagnostics in Blade files now point to the correct line in the original template instead of the virtual PHP line number.
5557
- **Spurious function auto-imports.** Import statements like `use function is_array;` were misidentified as function declarations, polluting the completion list with phantom entries that inserted incorrect imports.
5658
- **Duplicate `use function` insertion.** Accepting a function completion no longer inserts a `use function` statement when the exact import already exists in the file.
5759
- **Function import conflict handling.** When a different function with the same short name is already imported, completing a namespaced function now inserts the fully-qualified name instead of the ambiguous short name.

docs/todo/bugs.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,14 @@ handle this automatically.
2121
**Tests:** Assertion lines were removed from
2222
`tests/psalm_assertions/method_call.php` (out of scope until
2323
upstream stubs land).
24+
25+
26+
## B17. Blade `$loop` variable triggers unused-variable false positive
27+
28+
The Blade preprocessor injects `$loop = new \stdClass();` inside
29+
`@foreach` / `@forelse` blocks to provide completion for the magic
30+
`$loop` variable. When the template does not actually reference
31+
`$loop`, the unused-variable diagnostic flags it. The injected
32+
assignment should either be suppressed from unused-variable checks
33+
(e.g. by marking it as a synthetic definition) or the preprocessor
34+
should only inject it when `$loop` is actually used in the block.

examples/laravel/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ features against a real Laravel installation.
1010
- **View navigation.** Go-to-definition for `view('welcome')` and `View::make('admin.users.index')` (resolves to Blade templates in `resources/views/`).
1111
- **Route navigation.** Go-to-definition for `route('home')` (resolves to `->name('home')` in route files).
1212
- **Translation navigation.** Go-to-definition for `__('messages.welcome')`, `trans('auth.failed')`, and `trans_choice(...)` (resolves to `lang/` PHP files).
13+
- **Blade template intelligence.** Variable completion and hover in `{{ }}` expressions (shown as `e()` calls), go-to-definition on `@include`/`@extends` view references, `@forelse`/`@empty` directives, implicit `$loop` variable in `@foreach`/`@forelse`, implicit `$message` in `@error`, implicit `$value` in `@session`, `@verbatim` block handling, and standalone `@var` docblocks for type narrowing.
1314

1415
## Getting started
1516

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

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,27 +21,57 @@
2121
<p>Email: {{ $user->email }}</p>
2222
@endif
2323

24-
{{-- Foreach with model completion — $posts->byNewest() uses PostCollection --}}
25-
@foreach($posts->byNewest() as $post)
24+
{{-- @error injects an implicit $message variable --}}
25+
@error('email')
26+
<div class="alert">{{ $message }}</div>
27+
@enderror
28+
29+
{{-- @session injects an implicit $value variable --}}
30+
@session('status')
31+
<div class="success">{{ $value }}</div>
32+
@endsession
33+
34+
{{-- @forelse with @empty — supported since recently --}}
35+
@forelse($posts->published()->byNewest() as $post)
36+
{{-- Standalone @var docblock for type narrowing in foreach --}}
37+
@php /** @var \App\Models\BlogPost $post */ @endphp
2638
<article>
27-
<h2>{{ $post->title }}</h2>
39+
<h2>{{ $post->getTitle() }}</h2>
2840
<p>By {{ $post->author->name }}</p>
2941
<span>{{ $post->created_at->diffForHumans() }}</span>
42+
43+
{{-- $loop variable is injected automatically in foreach/forelse --}}
44+
@if($loop->first)
45+
<span class="badge">Featured</span>
46+
@endif
47+
@if(!$loop->last)
48+
<hr>
49+
@endif
3050
</article>
31-
@endforeach
51+
@empty
52+
<p>No posts found.</p>
53+
@endforelse
3254

33-
{{-- Route and translation helpers --}}
55+
{{-- Route and translation helpers (Ctrl+Click navigates to definition) --}}
3456
<nav>
57+
{{-- Go-to-definition works on view references in @include, @extends --}}
3558
<a href="{{ route('home') }}">{{ __('messages.welcome') }}</a>
3659
<a href="{{ route('admin.users.index') }}">{{ trans('auth.failed') }}</a>
3760
</nav>
3861

39-
{{-- Includes and nested views --}}
62+
{{-- @include: Ctrl+Click navigates to the included view --}}
4063
@include('emails.order_shipped', ['post' => $posts->first()])
4164

65+
{{-- @verbatim: content inside is skipped by the preprocessor --}}
66+
@verbatim
67+
<p>This {{ $blade }} syntax is not processed</p>
68+
@endverbatim
69+
4270
{{-- Conditional rendering with config --}}
4371
@if(config('app.debug'))
4472
<pre>Debug mode is on (env: {{ config('app.env') }})</pre>
4573
@endif
74+
75+
@yield('content')
4676
</body>
4777
</html>

src/analyse.rs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,22 @@ pub async fn run(options: AnalyseOptions) -> i32 {
311311
None => continue, // file that failed to read
312312
};
313313

314+
// For Blade files, use the preprocessed virtual PHP
315+
// content instead of the raw Blade template. The
316+
// virtual content was produced by `update_ast` in
317+
// Phase 1 and stored in `blade_virtual_content`.
318+
let blade_content;
319+
let content = if crate::blade::is_blade_file(uri) {
320+
if let Some(vc) = backend.blade_virtual_content.read().get(uri.as_str()) {
321+
blade_content = vc.clone();
322+
&blade_content
323+
} else {
324+
content
325+
}
326+
} else {
327+
content
328+
};
329+
314330
// Activate ONE parse cache for the entire file so
315331
// all collectors share the same parsed AST. Each
316332
// collector's own `with_parse_cache` call becomes
@@ -479,6 +495,16 @@ pub async fn run(options: AnalyseOptions) -> i32 {
479495
}
480496
}
481497

498+
// For Blade files, translate diagnostic ranges from
499+
// virtual PHP coordinates back to original Blade
500+
// coordinates so line numbers match the source file.
501+
let is_blade = crate::blade::is_blade_file(uri);
502+
let source_map = if is_blade {
503+
backend.blade_source_maps.read().get(uri.as_str()).cloned()
504+
} else {
505+
None
506+
};
507+
482508
let mut filtered: Vec<FileDiagnostic> = raw
483509
.into_iter()
484510
.filter_map(|d| {
@@ -490,8 +516,13 @@ pub async fn run(options: AnalyseOptions) -> i32 {
490516
Some(NumberOrString::String(s)) => Some(s.clone()),
491517
_ => None,
492518
};
519+
let line = if let Some(ref map) = source_map {
520+
map.php_to_blade(d.range.start).line + 1
521+
} else {
522+
d.range.start.line + 1
523+
};
493524
Some(FileDiagnostic {
494-
line: d.range.start.line + 1,
525+
line,
495526
message: d.message,
496527
identifier,
497528
severity: sev,

src/blade/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ pub mod source_map;
55
use std::path::{Path, PathBuf};
66

77
/// Number of lines the Blade preprocessor injects as a prologue
8-
/// (<?php header, $errors declaration, $__env declaration, etc.).
9-
pub const PROLOGUE_LINES: u32 = 5;
8+
/// (<?php header, $errors declaration, $__env declaration, wrapper function, etc.).
9+
pub const PROLOGUE_LINES: u32 = 6;
1010

1111
/// Check whether a URI refers to a Blade template file.
1212
pub fn is_blade_file(uri: &str) -> bool {

src/blade/preprocessor.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ pub fn preprocess(content: &str) -> (String, BladeSourceMap) {
2020
virtual_php.push_str("$errors = new \\Illuminate\\Support\\ViewErrorBag();\n");
2121
virtual_php.push_str("/** @var \\Illuminate\\View\\Factory $__env */\n");
2222
virtual_php.push_str("$__env = new \\Illuminate\\View\\Factory();\n");
23+
// Wrap the template body in a function so that diagnostic
24+
// collectors (which only analyse function/method bodies) treat
25+
// the Blade content as analysable code. The closing brace is
26+
// appended after the main loop.
27+
virtual_php.push_str("function __blade_template() {\n");
2328

2429
let mut in_php_directive_block = false;
2530
let mut mode = Mode::Html;
@@ -385,6 +390,9 @@ pub fn preprocess(content: &str) -> (String, BladeSourceMap) {
385390
source_map.adjustments.push(adjustments);
386391
}
387392

393+
// Close the wrapper function.
394+
virtual_php.push_str("}\n");
395+
388396
(virtual_php, source_map)
389397
}
390398

src/diagnostics/mod.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2711,7 +2711,7 @@ mod tests {
27112711
let content = "Hello World";
27122712
backend.update_ast(uri, content);
27132713

2714-
// First 5 lines are prologue.
2714+
// First 6 lines are prologue (including wrapper function declaration).
27152715
let virtual_php = {
27162716
let vc_handle = backend.blade_virtual_content.read();
27172717
vc_handle
@@ -2720,11 +2720,11 @@ mod tests {
27202720
.expect("Virtual content should exist")
27212721
};
27222722

2723-
// Find start of line 4 (0-indexed).
2723+
// Find start of line 5 (0-indexed).
27242724
let mut offset = 0;
27252725
let mut lines_seen = 0;
27262726
for (i, ch) in virtual_php.char_indices() {
2727-
if lines_seen == 4 {
2727+
if lines_seen == 5 {
27282728
offset = i;
27292729
break;
27302730
}
@@ -2737,11 +2737,11 @@ mod tests {
27372737
let range = backend.offset_range_to_lsp_range(uri, content, offset, offset + 5);
27382738
assert!(range.is_none(), "Diagnostic in prologue should be ignored");
27392739

2740-
// byte range after prologue (line 5+)
2740+
// byte range after prologue (line 6+)
27412741
let mut after_offset = 0;
27422742
let mut lines_seen = 0;
27432743
for (i, ch) in virtual_php.char_indices() {
2744-
if lines_seen == 5 {
2744+
if lines_seen == 6 {
27452745
after_offset = i;
27462746
break;
27472747
}

src/diagnostics/unknown_members/mod.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,6 @@ use crate::types::{AccessKind, ClassInfo, ClassLikeKind};
8989
use crate::virtual_members::resolve_class_fully_cached;
9090

9191
use super::helpers::{compute_existence_guards, find_innermost_enclosing_class, make_diagnostic};
92-
use super::offset_range_to_lsp_range;
9392

9493
/// Diagnostic code used for unknown-member diagnostics so that code
9594
/// actions can match on it.
@@ -554,6 +553,7 @@ impl Backend {
554553

555554
SubjectOutcome::Resolved(ref base_classes) => {
556555
let (result, coarse_diags) = self.check_member_on_resolved_classes(
556+
uri,
557557
base_classes,
558558
member_name,
559559
is_static,
@@ -591,6 +591,7 @@ impl Backend {
591591
if let SubjectOutcome::Resolved(ref fresh_classes) = fresh {
592592
// Use the fresh diagnostics instead of the coarse ones.
593593
self.check_member_on_resolved_classes(
594+
uri,
594595
fresh_classes,
595596
member_name,
596597
is_static,
@@ -642,6 +643,7 @@ impl Backend {
642643
#[allow(clippy::too_many_arguments)]
643644
fn check_member_on_resolved_classes(
644645
&self,
646+
uri: &str,
645647
base_classes: &[Arc<ClassInfo>],
646648
member_name: &str,
647649
is_static: bool,
@@ -754,7 +756,8 @@ impl Backend {
754756
}
755757

756758
// ── Member is unresolved on ALL branches — emit diagnostic ──
757-
let range = match offset_range_to_lsp_range(content, start as usize, end as usize) {
759+
let range = match self.offset_range_to_lsp_range(uri, content, start as usize, end as usize)
760+
{
758761
Some(r) => r,
759762
None => return (MemberCheckResult::Ok, diagnostics),
760763
};

0 commit comments

Comments
 (0)