Skip to content

Commit 4db156b

Browse files
committed
Fix Blade hover range translation and standalone @var completion
1 parent 3b7eedd commit 4db156b

8 files changed

Lines changed: 246 additions & 4 deletions

File tree

docs/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- **Blade template support.** Completion, hover, go-to-definition, diagnostics, semantic tokens, and inlay hints work inside `.blade.php` files. The preprocessor transforms Blade syntax into virtual PHP on the fly, with coordinate translation so all editor features report correct positions. ([#100](https://github.com/AJenbo/phpantom_lsp/pull/100) by [@MingJen](https://github.com/MingJen))
1414
- **Blade keyword highlighting.** Blade directives, echo delimiters, PHP keywords, cast types, comments, and PHPDoc tags inside `.blade.php` files now receive semantic tokens for proper syntax coloring. PHP keywords and cast types are emitted from the AST and shared with `.php` files, so highlighting is fully in sync between both file types.
1515

16+
### Fixed
17+
18+
- **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.
19+
- **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+
1621
### Changed
1722

1823
- **Unified type resolution.** Hover, go-to-type-definition, find-references, deprecated diagnostics, and replace-deprecated code actions now share a single variable/subject resolution pipeline. Fixes resolved in one feature automatically apply to all others.

examples/laravel/resources/views/admin/users/index.blade.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
{{-- PHPantom Laravel Demo: Admin Users Index --}}
21
{{-- Demonstrates completion and navigation in nested Blade views --}}
32
@php
43
/**

examples/laravel/resources/views/emails/blog_published.blade.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
{{-- PHPantom Laravel Demo: Blog Post Published Email --}}
21
{{-- Demonstrates variable completion inside included partials --}}
32
@php
43
/**

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
{{-- PHPantom Laravel Demo: Blade Template --}}
21
{{-- Try: Ctrl+Click on variables, config keys, route names, and translation keys --}}
32
@php
43
/**

src/completion/resolver.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1316,6 +1316,36 @@ fn resolve_variable_fallback(
13161316
vec![]
13171317
};
13181318

1319+
// ── @var docblock fallback ───────────────────────────────────
1320+
// When the statement walk found no assignments for this variable,
1321+
// check for a standalone `/** @var Type $var */` annotation above
1322+
// the cursor. This handles Blade templates and files where the
1323+
// only type source is a docblock assertion.
1324+
let resolved_types = if resolved_types.is_empty() && is_bare_variable {
1325+
let prefixed = if var_name.starts_with('$') {
1326+
var_name.to_string()
1327+
} else {
1328+
format!("${}", var_name)
1329+
};
1330+
if let Some(var_type) = crate::docblock::find_var_raw_type_in_source(
1331+
ctx.content,
1332+
ctx.cursor_offset as usize,
1333+
&prefixed,
1334+
) {
1335+
let classes = super::type_resolution::type_hint_to_classes_typed(
1336+
&var_type,
1337+
&effective_class.name,
1338+
all_classes,
1339+
class_loader,
1340+
);
1341+
classes.into_iter().map(ResolvedType::from_arc).collect()
1342+
} else {
1343+
vec![]
1344+
}
1345+
} else {
1346+
resolved_types
1347+
};
1348+
13191349
// ── `class-string<T>` unwrapping for `$var::` access ────────
13201350
// When the variable's type is `class-string<T>` (e.g. from a
13211351
// `@param class-string<BackedEnum> $class` annotation) and the

src/server.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -641,7 +641,14 @@ impl LanguageServer for Backend {
641641
let uri_clone = uri.clone();
642642
tokio::task::spawn_blocking(move || {
643643
backend.handle_with_position("hover", &uri_clone, position, |content, pos| {
644-
backend.handle_hover(&uri_clone, content, pos)
644+
let mut hover = backend.handle_hover(&uri_clone, content, pos)?;
645+
if crate::blade::is_blade_file(&uri_clone)
646+
&& let Some(range) = &mut hover.range
647+
{
648+
range.start = backend.translate_php_to_blade(&uri_clone, range.start);
649+
range.end = backend.translate_php_to_blade(&uri_clone, range.end);
650+
}
651+
Some(hover)
645652
})
646653
})
647654
.await

tests/integration/blade_debug.rs

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
#[cfg(test)]
2+
mod tests {
3+
use crate::common::create_test_backend;
4+
use tower_lsp::LanguageServer;
5+
use tower_lsp::lsp_types::*;
6+
7+
#[tokio::test]
8+
async fn test_blade_hover_range_translated_back() {
9+
let backend = create_test_backend();
10+
11+
let php_uri = Url::parse("file:///BlogAuthor.php").unwrap();
12+
let php_text =
13+
"<?php namespace App\\Models; class BlogAuthor { public string $name = ''; }";
14+
backend
15+
.did_open(DidOpenTextDocumentParams {
16+
text_document: TextDocumentItem {
17+
uri: php_uri.clone(),
18+
language_id: "php".to_string(),
19+
version: 1,
20+
text: php_text.to_string(),
21+
},
22+
})
23+
.await;
24+
25+
let blade_uri = Url::parse("file:///email.blade.php").unwrap();
26+
// Line 0: @php
27+
// Line 1: /**
28+
// Line 2: * @var \App\Models\BlogAuthor $author
29+
// Line 3: */
30+
// Line 4: @endphp
31+
// Line 5: (empty)
32+
// Line 6: <p>{{ $author->name }}</p>
33+
let blade_text = "@php\n/**\n * @var \\App\\Models\\BlogAuthor $author\n */\n@endphp\n\n<p>{{ $author->name }}</p>";
34+
35+
backend
36+
.did_open(DidOpenTextDocumentParams {
37+
text_document: TextDocumentItem {
38+
uri: blade_uri.clone(),
39+
language_id: "blade".to_string(),
40+
version: 1,
41+
text: blade_text.to_string(),
42+
},
43+
})
44+
.await;
45+
46+
// Hover on $author (line 6, character 6 = the '$' of $author)
47+
let params = HoverParams {
48+
text_document_position_params: TextDocumentPositionParams {
49+
text_document: TextDocumentIdentifier {
50+
uri: blade_uri.clone(),
51+
},
52+
position: Position {
53+
line: 6,
54+
character: 6,
55+
},
56+
},
57+
work_done_progress_params: WorkDoneProgressParams::default(),
58+
};
59+
60+
let result = backend.hover(params).await.unwrap();
61+
assert!(result.is_some(), "Should return hover for $author");
62+
63+
let hover = result.unwrap();
64+
// The range should be in Blade space (line 6), not PHP virtual space (line 11)
65+
if let Some(range) = hover.range {
66+
assert_eq!(
67+
range.start.line, 6,
68+
"Hover range start line should be in Blade space (6), got {}",
69+
range.start.line
70+
);
71+
}
72+
}
73+
74+
#[tokio::test]
75+
async fn test_blade_completion_with_var_annotation() {
76+
let backend = create_test_backend();
77+
78+
let php_uri = Url::parse("file:///BlogAuthor.php").unwrap();
79+
let php_text = "<?php namespace App\\Models; class BlogAuthor { public string $name = ''; public int $age = 0; }";
80+
backend
81+
.did_open(DidOpenTextDocumentParams {
82+
text_document: TextDocumentItem {
83+
uri: php_uri.clone(),
84+
language_id: "php".to_string(),
85+
version: 1,
86+
text: php_text.to_string(),
87+
},
88+
})
89+
.await;
90+
91+
let blade_uri = Url::parse("file:///email.blade.php").unwrap();
92+
let blade_text = "@php\n/**\n * @var \\App\\Models\\BlogAuthor $author\n */\n@endphp\n\n<p>{{ $author-> }}</p>";
93+
94+
backend
95+
.did_open(DidOpenTextDocumentParams {
96+
text_document: TextDocumentItem {
97+
uri: blade_uri.clone(),
98+
language_id: "blade".to_string(),
99+
version: 1,
100+
text: blade_text.to_string(),
101+
},
102+
})
103+
.await;
104+
105+
// Complete after "$author->" on line 6
106+
// "<p>{{ $author-> }}</p>"
107+
// 0123456789012345
108+
let params = CompletionParams {
109+
text_document_position: TextDocumentPositionParams {
110+
text_document: TextDocumentIdentifier {
111+
uri: blade_uri.clone(),
112+
},
113+
position: Position {
114+
line: 6,
115+
character: 15, // after "$author->"
116+
},
117+
},
118+
work_done_progress_params: WorkDoneProgressParams::default(),
119+
partial_result_params: PartialResultParams::default(),
120+
context: Some(CompletionContext {
121+
trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
122+
trigger_character: Some(">".to_string()),
123+
}),
124+
};
125+
126+
let result = backend.completion(params).await.unwrap();
127+
assert!(result.is_some(), "Should return completions for $author->");
128+
129+
let items = match result.unwrap() {
130+
CompletionResponse::Array(items) => items,
131+
CompletionResponse::List(list) => list.items,
132+
};
133+
134+
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
135+
assert!(
136+
labels.contains(&"name"),
137+
"Should complete 'name' property, got: {:?}",
138+
labels
139+
);
140+
assert!(
141+
labels.contains(&"age"),
142+
"Should complete 'age' property, got: {:?}",
143+
labels
144+
);
145+
}
146+
147+
#[tokio::test]
148+
async fn test_blade_goto_definition_member() {
149+
let backend = create_test_backend();
150+
151+
let php_uri = Url::parse("file:///BlogAuthor.php").unwrap();
152+
let php_text =
153+
"<?php namespace App\\Models; class BlogAuthor { public string $name = ''; }";
154+
backend
155+
.did_open(DidOpenTextDocumentParams {
156+
text_document: TextDocumentItem {
157+
uri: php_uri.clone(),
158+
language_id: "php".to_string(),
159+
version: 1,
160+
text: php_text.to_string(),
161+
},
162+
})
163+
.await;
164+
165+
let blade_uri = Url::parse("file:///email.blade.php").unwrap();
166+
let blade_text = "@php\n/**\n * @var \\App\\Models\\BlogAuthor $author\n */\n@endphp\n\n<p>{{ $author->name }}</p>";
167+
168+
backend
169+
.did_open(DidOpenTextDocumentParams {
170+
text_document: TextDocumentItem {
171+
uri: blade_uri.clone(),
172+
language_id: "blade".to_string(),
173+
version: 1,
174+
text: blade_text.to_string(),
175+
},
176+
})
177+
.await;
178+
179+
// GTD on "name" in "$author->name" on line 6
180+
// "<p>{{ $author->name }}</p>"
181+
// 0123456789012345678
182+
let params = GotoDefinitionParams {
183+
text_document_position_params: TextDocumentPositionParams {
184+
text_document: TextDocumentIdentifier {
185+
uri: blade_uri.clone(),
186+
},
187+
position: Position {
188+
line: 6,
189+
character: 16, // "name"
190+
},
191+
},
192+
work_done_progress_params: WorkDoneProgressParams::default(),
193+
partial_result_params: PartialResultParams::default(),
194+
};
195+
196+
let result = backend.goto_definition(params).await.unwrap();
197+
assert!(
198+
result.is_some(),
199+
"Should resolve definition for ->name in Blade file"
200+
);
201+
}
202+
}

tests/integration/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
mod common;
22

33
mod blade;
4+
pub mod blade_debug;
45
pub mod blade_error;
56
pub mod blade_regression;
67
mod classmap_scanner;

0 commit comments

Comments
 (0)