Skip to content

Commit 8adcbb4

Browse files
committed
Docblock Override
1 parent e502e65 commit 8adcbb4

4 files changed

Lines changed: 223 additions & 36 deletions

File tree

docs/todo.md

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -30,29 +30,6 @@ target.
3030

3131
### Remaining by user need
3232

33-
#### 39. `@var` type annotation without variable name not resolved
34-
35-
When a `@var` docblock includes the variable name, the type is picked up
36-
correctly. When the variable name is omitted (a common shorthand), the
37-
type is not applied to the next assignment.
38-
39-
```php
40-
// Works:
41-
/** @var User $red */
42-
$red = a();
43-
$red-> // ← completes User members
44-
45-
// Does not work:
46-
/** @var User */
47-
$red = a();
48-
$red-> // ← no completion
49-
```
50-
51-
**Fix:** when `@var` has a type but no variable name, apply the type to
52-
the immediately following assignment statement.
53-
54-
---
55-
5633
#### 40. No nested key completion for literal array assignments
5734

5835
When a variable is assigned a literal array with nested associative

example.php

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,25 @@
5959
$user->render(); // @method (Renderable interface)
6060

6161

62+
// ── @var Docblock Override ──────────────────────────────────────────────────
63+
// The variable name in @var is optional. Both forms work for completion and go-to-definition.
64+
65+
/** @var AdminUser $inlineHinted */
66+
$inlineHinted = getUnknownValue();
67+
$inlineHinted->grantPermission('write'); // with explicit variable name
68+
69+
/** @var User */
70+
$hinted = getUnknownValue();
71+
$hinted->getEmail(); // type from @var, no variable name needed
72+
73+
6274
// ── String Interpolation ────────────────────────────────────────────────────
6375
// Completion is suppressed inside plain string content but still works in
6476
// PHP interpolation contexts. Try: delete the property name after -> and
6577
// trigger completion to see members offered.
6678

67-
$greeting = "Hello {$user->getProfile()->bio}"; // brace interpolation — completion works
68-
$info = "Name: $user->displayName"; // simple interpolation — completion works
79+
$greeting = "Hello {$user->getProfile()->bio}"; // brace interpolation — full completion
80+
$info = "Name: $user->displayName"; // simple interpolation — completion only for valid items
6981
$nope = 'no $user-> here'; // single-quoted — completion suppressed
7082
$plain = "just plain text"; // no $ — completion suppressed
7183

@@ -208,17 +220,6 @@ function handleIntersection(User&Loggable $entity): void {
208220
$p->getDisplayName(); // Profile → UserProfile via `use ... as`
209221

210222

211-
// ── @var Docblock Override ──────────────────────────────────────────────────
212-
213-
/** @var User $hinted */
214-
$hinted = getUnknownValue();
215-
$hinted->getEmail(); // type from @var, not mixed
216-
217-
/** @var AdminUser $inlineHinted */
218-
$inlineHinted = getUnknownValue();
219-
$inlineHinted->grantPermission('write');
220-
221-
222223
// ── Ambiguous Variables ─────────────────────────────────────────────────────
223224

224225
if (rand(0, 1)) {

tests/completion_variables.rs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2494,6 +2494,151 @@ async fn test_completion_inline_var_docblock_override_allowed_for_object() {
24942494
}
24952495
}
24962496

2497+
/// Inline `@var` without variable name at top level resolves the type
2498+
/// for the immediately following assignment.
2499+
#[tokio::test]
2500+
async fn test_completion_inline_var_docblock_no_varname_top_level() {
2501+
let backend = create_test_backend();
2502+
2503+
let uri = Url::parse("file:///inlinevar_toplevel.php").unwrap();
2504+
let text = concat!(
2505+
"<?php\n",
2506+
"class User {\n",
2507+
" public string $name;\n",
2508+
" public function getEmail(): string {}\n",
2509+
"}\n",
2510+
"/** @var User */\n",
2511+
"$red = a();\n",
2512+
"$red->\n",
2513+
);
2514+
2515+
let open_params = DidOpenTextDocumentParams {
2516+
text_document: TextDocumentItem {
2517+
uri: uri.clone(),
2518+
language_id: "php".to_string(),
2519+
version: 1,
2520+
text: text.to_string(),
2521+
},
2522+
};
2523+
backend.did_open(open_params).await;
2524+
2525+
let completion_params = CompletionParams {
2526+
text_document_position: TextDocumentPositionParams {
2527+
text_document: TextDocumentIdentifier { uri },
2528+
position: Position {
2529+
line: 7,
2530+
character: 6,
2531+
},
2532+
},
2533+
work_done_progress_params: WorkDoneProgressParams::default(),
2534+
partial_result_params: PartialResultParams::default(),
2535+
context: None,
2536+
};
2537+
2538+
let result = backend.completion(completion_params).await.unwrap();
2539+
assert!(
2540+
result.is_some(),
2541+
"Should return completions for @var User without variable name at top level"
2542+
);
2543+
2544+
match result.unwrap() {
2545+
CompletionResponse::Array(items) => {
2546+
let names: Vec<&str> = items
2547+
.iter()
2548+
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
2549+
.collect();
2550+
assert!(
2551+
names.contains(&"name"),
2552+
"Should include 'name' from User, got: {:?}",
2553+
names
2554+
);
2555+
assert!(
2556+
names.contains(&"getEmail"),
2557+
"Should include 'getEmail' from User, got: {:?}",
2558+
names
2559+
);
2560+
}
2561+
_ => panic!("Expected CompletionResponse::Array"),
2562+
}
2563+
}
2564+
2565+
/// Inline `@var` without variable name for a cross-file class at top level.
2566+
#[tokio::test]
2567+
async fn test_completion_inline_var_docblock_no_varname_cross_file() {
2568+
let (backend, _dir) = create_psr4_workspace(
2569+
r#"{ "autoload": { "psr-4": { "App\\": "src/" } } }"#,
2570+
&[(
2571+
"src/Models/User.php",
2572+
concat!(
2573+
"<?php\n",
2574+
"namespace App\\Models;\n",
2575+
"class User {\n",
2576+
" public string $name;\n",
2577+
" public function getEmail(): string {}\n",
2578+
"}\n",
2579+
),
2580+
)],
2581+
);
2582+
2583+
let uri = Url::parse("file:///crossfile_inlinevar.php").unwrap();
2584+
let text = concat!(
2585+
"<?php\n",
2586+
"use App\\Models\\User;\n",
2587+
"/** @var User */\n",
2588+
"$red = a();\n",
2589+
"$red->\n",
2590+
);
2591+
2592+
let open_params = DidOpenTextDocumentParams {
2593+
text_document: TextDocumentItem {
2594+
uri: uri.clone(),
2595+
language_id: "php".to_string(),
2596+
version: 1,
2597+
text: text.to_string(),
2598+
},
2599+
};
2600+
backend.did_open(open_params).await;
2601+
2602+
let completion_params = CompletionParams {
2603+
text_document_position: TextDocumentPositionParams {
2604+
text_document: TextDocumentIdentifier { uri },
2605+
position: Position {
2606+
line: 4,
2607+
character: 6,
2608+
},
2609+
},
2610+
work_done_progress_params: WorkDoneProgressParams::default(),
2611+
partial_result_params: PartialResultParams::default(),
2612+
context: None,
2613+
};
2614+
2615+
let result = backend.completion(completion_params).await.unwrap();
2616+
assert!(
2617+
result.is_some(),
2618+
"Should return completions for cross-file @var User without variable name"
2619+
);
2620+
2621+
match result.unwrap() {
2622+
CompletionResponse::Array(items) => {
2623+
let names: Vec<&str> = items
2624+
.iter()
2625+
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
2626+
.collect();
2627+
assert!(
2628+
names.contains(&"name"),
2629+
"Should include 'name' from User, got: {:?}",
2630+
names
2631+
);
2632+
assert!(
2633+
names.contains(&"getEmail"),
2634+
"Should include 'getEmail' from User, got: {:?}",
2635+
names
2636+
);
2637+
}
2638+
_ => panic!("Expected CompletionResponse::Array"),
2639+
}
2640+
}
2641+
24972642
#[tokio::test]
24982643
async fn test_completion_inline_var_docblock_unconditional_reassignment() {
24992644
let backend = create_test_backend();

tests/definition_variables.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,70 @@ async fn test_goto_definition_inline_var_docblock_simple() {
625625
}
626626
}
627627

628+
/// When the `@var` annotation omits the variable name (`/** @var Type */`),
629+
/// goto definition should apply the type to the immediately following
630+
/// assignment and resolve the member correctly.
631+
#[tokio::test]
632+
async fn test_goto_definition_inline_var_docblock_no_variable_name() {
633+
let backend = create_test_backend();
634+
635+
let uri = Url::parse("file:///varoverride_noname.php").unwrap();
636+
let text = concat!(
637+
"<?php\n", // 0
638+
"class Session {\n", // 1
639+
" public function getId(): string {}\n", // 2
640+
" public function flash(): void {}\n", // 3
641+
"}\n", // 4
642+
"class Controller {\n", // 5
643+
" public function handle() {\n", // 6
644+
" /** @var Session */\n", // 7
645+
" $sess = mystery();\n", // 8
646+
" $sess->flash();\n", // 9
647+
" }\n", // 10
648+
"}\n", // 11
649+
);
650+
651+
let open_params = DidOpenTextDocumentParams {
652+
text_document: TextDocumentItem {
653+
uri: uri.clone(),
654+
language_id: "php".to_string(),
655+
version: 1,
656+
text: text.to_string(),
657+
},
658+
};
659+
backend.did_open(open_params).await;
660+
661+
// Click on "flash" on line 9: $sess->flash()
662+
let params = GotoDefinitionParams {
663+
text_document_position_params: TextDocumentPositionParams {
664+
text_document: TextDocumentIdentifier { uri: uri.clone() },
665+
position: Position {
666+
line: 9,
667+
character: 16,
668+
},
669+
},
670+
work_done_progress_params: WorkDoneProgressParams::default(),
671+
partial_result_params: PartialResultParams::default(),
672+
};
673+
674+
let result = backend.goto_definition(params).await.unwrap();
675+
assert!(
676+
result.is_some(),
677+
"Should resolve $sess->flash() via @var Session annotation (no variable name)"
678+
);
679+
680+
match result.unwrap() {
681+
GotoDefinitionResponse::Scalar(location) => {
682+
assert_eq!(location.uri, uri);
683+
assert_eq!(
684+
location.range.start.line, 3,
685+
"flash() is declared on line 3 in Session"
686+
);
687+
}
688+
other => panic!("Expected Scalar location, got: {:?}", other),
689+
}
690+
}
691+
628692
/// When the `@var` annotation includes a variable name (`@var Type $var`),
629693
/// goto definition should still resolve correctly.
630694
#[tokio::test]

0 commit comments

Comments
 (0)