Skip to content

Commit c3638ce

Browse files
committed
Support go-to-definition for @see tags in floating docblocks
1 parent 401cb65 commit c3638ce

3 files changed

Lines changed: 245 additions & 0 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5656
- **Scope methods missing from completion on relationship results.** Scope methods from related models now appear in completions, not just hover.
5757
- **Closure and variable hover now preserves generic arguments.** Closure parameters inferred from callable signatures, variables assigned from chained methods returning `static`/`$this`/`self`, and hovering on the `$` sign of a variable at its assignment site all now show the correct generic type.
5858
- **Callable parameter inference preserves generic arguments from the receiver.** A closure typed as `fn(Builder $q)` inside a `Builder<Product>` chain now infers `$q` as `Builder<Product>`, so model-specific scope methods resolve correctly.
59+
- **`@see` tags in floating docblocks now support go-to-definition.** Docblock comments not directly attached to a class, function, or statement (e.g. inline `/** @see SupervisorOptions::$balanceCooldown */` inside array literals or after expressions) are now parsed for symbol references. Previously these were silently ignored, particularly in files without a namespace.
5960
- **Nullable `static` return types on inherited methods.** Methods returning `?static` or `static|null` now correctly resolve to the calling subclass across files.
6061
- **Template binding with nested generics.** Parameter types like `Wrapper<Collection<T>, V>` no longer break during template binding.
6162
- **Single generic argument on collections bound to the wrong template parameter.** `Collection<User>` now binds to the value parameter instead of the key parameter when key-like template parameters precede value parameters.

src/symbol_map/extraction.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,19 @@ pub(crate) fn extract_symbol_map(program: &Program<'_>, content: &str) -> Symbol
9191
extract_from_statement(stmt, &mut ctx, 0);
9292
}
9393

94+
// ── Sweep all docblock trivia for floating references ───────────
95+
// Docblocks attached to classes, functions, methods, properties, and
96+
// certain statements are already processed during the AST walk above.
97+
// However, docblocks in other positions (e.g. inline `/** @see ... */`
98+
// inside array literals or after expressions) are never visited.
99+
// Scan every docblock trivia entry and extract symbols; the dedup
100+
// step below removes any duplicates from already-processed docblocks.
101+
for t in program.trivia.iter() {
102+
if t.kind == TriviaKind::DocBlockComment {
103+
let _tpl = extract_docblock_symbols(t.value, t.span.start.offset, &mut ctx.spans);
104+
}
105+
}
106+
94107
// Sort by start offset for binary search.
95108
ctx.spans.sort_by_key(|s| s.start);
96109

tests/integration/definition_members.rs

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3821,3 +3821,234 @@ async fn test_goto_definition_trait_alias_original_name_when_class_overrides() {
38213821
other => panic!("Expected Scalar location, got: {:?}", other),
38223822
}
38233823
}
3824+
3825+
// ─── @see Tag GTD in Floating Docblocks ─────────────────────────────────────
3826+
3827+
#[tokio::test]
3828+
async fn test_goto_definition_see_tag_in_array_no_namespace() {
3829+
// Regression: @see tags in floating docblocks (e.g. inside array
3830+
// literals, in files without a namespace) were not parsed because
3831+
// the docblock wasn't attached to any AST node.
3832+
let backend = create_test_backend();
3833+
3834+
let uri = Url::parse("file:///test_see_array.php").unwrap();
3835+
// Line 0: <?php
3836+
// Line 1: class SupervisorOptions {
3837+
// Line 2: public int $balanceCooldown = 3;
3838+
// Line 3: }
3839+
// Line 4: $defaults = [
3840+
// Line 5: 'balanceCooldown' => 3, /** @see SupervisorOptions::$balanceCooldown */
3841+
// Line 6: ];
3842+
let text = concat!(
3843+
"<?php\n",
3844+
"class SupervisorOptions {\n",
3845+
" public int $balanceCooldown = 3;\n",
3846+
"}\n",
3847+
"$defaults = [\n",
3848+
" 'balanceCooldown' => 3, /** @see SupervisorOptions::$balanceCooldown */\n",
3849+
"];\n",
3850+
);
3851+
3852+
let open_params = DidOpenTextDocumentParams {
3853+
text_document: TextDocumentItem {
3854+
uri: uri.clone(),
3855+
language_id: "php".to_string(),
3856+
version: 1,
3857+
text: text.to_string(),
3858+
},
3859+
};
3860+
backend.did_open(open_params).await;
3861+
3862+
// Click on "SupervisorOptions" in the @see tag (line 5)
3863+
let params = GotoDefinitionParams {
3864+
text_document_position_params: TextDocumentPositionParams {
3865+
text_document: TextDocumentIdentifier { uri: uri.clone() },
3866+
position: Position {
3867+
line: 5,
3868+
character: 40, // within "SupervisorOptions"
3869+
},
3870+
},
3871+
work_done_progress_params: WorkDoneProgressParams::default(),
3872+
partial_result_params: PartialResultParams::default(),
3873+
};
3874+
3875+
let result = backend.goto_definition(params).await.unwrap();
3876+
assert!(
3877+
result.is_some(),
3878+
"Should resolve @see class reference in floating docblock inside array literal"
3879+
);
3880+
3881+
match result.unwrap() {
3882+
GotoDefinitionResponse::Scalar(location) => {
3883+
assert_eq!(
3884+
location.range.start.line, 1,
3885+
"SupervisorOptions is defined on line 1"
3886+
);
3887+
}
3888+
other => panic!("Expected Scalar location, got: {:?}", other),
3889+
}
3890+
3891+
// Click on "$balanceCooldown" in the @see tag (line 5)
3892+
let params = GotoDefinitionParams {
3893+
text_document_position_params: TextDocumentPositionParams {
3894+
text_document: TextDocumentIdentifier { uri: uri.clone() },
3895+
position: Position {
3896+
line: 5,
3897+
character: 62, // within "$balanceCooldown"
3898+
},
3899+
},
3900+
work_done_progress_params: WorkDoneProgressParams::default(),
3901+
partial_result_params: PartialResultParams::default(),
3902+
};
3903+
3904+
let result = backend.goto_definition(params).await.unwrap();
3905+
assert!(
3906+
result.is_some(),
3907+
"Should resolve @see member reference in floating docblock inside array literal"
3908+
);
3909+
3910+
match result.unwrap() {
3911+
GotoDefinitionResponse::Scalar(location) => {
3912+
assert_eq!(
3913+
location.range.start.line, 2,
3914+
"$balanceCooldown property is defined on line 2"
3915+
);
3916+
}
3917+
other => panic!("Expected Scalar location, got: {:?}", other),
3918+
}
3919+
}
3920+
3921+
#[tokio::test]
3922+
async fn test_goto_definition_see_tag_inline_in_expression() {
3923+
// A floating /** @see ... */ after an expression statement, not
3924+
// attached to any class or function.
3925+
let backend = create_test_backend();
3926+
3927+
let uri = Url::parse("file:///test_see_inline.php").unwrap();
3928+
// Line 0: <?php
3929+
// Line 1: class Config {
3930+
// Line 2: public string $timeout = '30';
3931+
// Line 3: }
3932+
// Line 4:
3933+
// Line 5: $timeout = 30; /** @see Config::$timeout */
3934+
let text = concat!(
3935+
"<?php\n",
3936+
"class Config {\n",
3937+
" public string $timeout = '30';\n",
3938+
"}\n",
3939+
"\n",
3940+
"$timeout = 30; /** @see Config::$timeout */\n",
3941+
);
3942+
3943+
let open_params = DidOpenTextDocumentParams {
3944+
text_document: TextDocumentItem {
3945+
uri: uri.clone(),
3946+
language_id: "php".to_string(),
3947+
version: 1,
3948+
text: text.to_string(),
3949+
},
3950+
};
3951+
backend.did_open(open_params).await;
3952+
3953+
// Click on "Config" in the @see tag (line 5)
3954+
let params = GotoDefinitionParams {
3955+
text_document_position_params: TextDocumentPositionParams {
3956+
text_document: TextDocumentIdentifier { uri: uri.clone() },
3957+
position: Position {
3958+
line: 5,
3959+
character: 25, // within "Config"
3960+
},
3961+
},
3962+
work_done_progress_params: WorkDoneProgressParams::default(),
3963+
partial_result_params: PartialResultParams::default(),
3964+
};
3965+
3966+
let result = backend.goto_definition(params).await.unwrap();
3967+
assert!(
3968+
result.is_some(),
3969+
"Should resolve @see class reference in trailing docblock after expression"
3970+
);
3971+
3972+
match result.unwrap() {
3973+
GotoDefinitionResponse::Scalar(location) => {
3974+
assert_eq!(location.range.start.line, 1, "Config is defined on line 1");
3975+
}
3976+
other => panic!("Expected Scalar location, got: {:?}", other),
3977+
}
3978+
}
3979+
3980+
#[tokio::test]
3981+
async fn test_goto_definition_see_tag_cross_file_no_namespace() {
3982+
// @see in a file without namespace pointing to a class in another file.
3983+
let (backend, _dir) = create_psr4_workspace(
3984+
r#"{
3985+
"autoload": {
3986+
"psr-4": {
3987+
"App\\": "src/"
3988+
}
3989+
}
3990+
}"#,
3991+
&[(
3992+
"src/Models/SupervisorOptions.php",
3993+
concat!(
3994+
"<?php\n",
3995+
"namespace App\\Models;\n",
3996+
"class SupervisorOptions {\n",
3997+
" public int $balanceCooldown = 3;\n",
3998+
"}\n",
3999+
),
4000+
)],
4001+
);
4002+
4003+
let config_uri = Url::parse("file:///config/horizon.php").unwrap();
4004+
let config_text = concat!(
4005+
"<?php\n",
4006+
"use App\\Models\\SupervisorOptions;\n",
4007+
"$defaults = [\n",
4008+
" 'balanceCooldown' => 3, /** @see SupervisorOptions::$balanceCooldown */\n",
4009+
"];\n",
4010+
);
4011+
4012+
let open_params = DidOpenTextDocumentParams {
4013+
text_document: TextDocumentItem {
4014+
uri: config_uri.clone(),
4015+
language_id: "php".to_string(),
4016+
version: 1,
4017+
text: config_text.to_string(),
4018+
},
4019+
};
4020+
backend.did_open(open_params).await;
4021+
4022+
// Click on "SupervisorOptions" in the @see tag (line 3)
4023+
let params = GotoDefinitionParams {
4024+
text_document_position_params: TextDocumentPositionParams {
4025+
text_document: TextDocumentIdentifier {
4026+
uri: config_uri.clone(),
4027+
},
4028+
position: Position {
4029+
line: 3,
4030+
character: 40, // within "SupervisorOptions"
4031+
},
4032+
},
4033+
work_done_progress_params: WorkDoneProgressParams::default(),
4034+
partial_result_params: PartialResultParams::default(),
4035+
};
4036+
4037+
let result = backend.goto_definition(params).await.unwrap();
4038+
assert!(
4039+
result.is_some(),
4040+
"Should resolve @see class reference cross-file from a no-namespace config file"
4041+
);
4042+
4043+
match result.unwrap() {
4044+
GotoDefinitionResponse::Scalar(location) => {
4045+
let path = location.uri.to_file_path().unwrap();
4046+
assert!(
4047+
path.ends_with("Models/SupervisorOptions.php"),
4048+
"Should point to SupervisorOptions.php, got: {:?}",
4049+
path
4050+
);
4051+
}
4052+
other => panic!("Expected Scalar location, got: {:?}", other),
4053+
}
4054+
}

0 commit comments

Comments
 (0)