Skip to content

Commit 6348c6c

Browse files
committed
fix: go-to-definition on overrides jumps to parent declaration
When the cursor is on a method definition that overrides a parent or implements an interface method, go-to-definition now navigates to the prototype declaration. Previously it returned call-site references (find_references), which sent the user to a seemingly random usage. Similarly, go-to-definition on a class declaration name now jumps to the parent class when one exists, instead of returning usages. The reverse-implementation lookup (already used by textDocument/implementation) is now reused by the definition handler for MemberDeclaration symbols. Methods and classes that do not override anything still fall through to the usages behavior. Closes #236
1 parent 445c65a commit 6348c6c

7 files changed

Lines changed: 141 additions & 110 deletions

File tree

docs/CHANGELOG.md

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

4242
### Fixed
4343

44+
- **Go-to-definition on overriding methods jumps to the parent declaration.** When the cursor is on a method definition that overrides a parent or implements an interface method, go-to-definition now navigates to the prototype declaration instead of returning call-site references. Similarly, go-to-definition on a class name jumps to the parent class when one exists. Methods and classes that don't override anything still show usages as before. Contributed by @calebdw.
45+
- **Code lens navigation works on Cursor, VSCodium, Neovim, and other editors.** Clicking a code lens annotation (e.g. "overrides Parent::method") now uses the standard `window/showDocument` LSP request instead of editor-specific commands (`vscode.open`, `editor.action.showReferences`) that only worked on VS Code. Contributed by @calebdw.
4446
- **Go-to-implementation works when interface and class share the same short name.** An interface and its implementing class in different namespaces but with the same class name (e.g. `App\Contracts\HttpClient` and `App\Foo\HttpClient`) now resolves correctly instead of returning no results. Contributed by @calebdw.
4547
- **Static method calls resolve return types as accurately as instance calls.** `Foo::bar()` previously missed inference that `$foo->bar()` already had: return types behind a `@phpstan-type` alias, inherited return types substituted through a generic interface or trait, and the `__callStatic()` magic-method fallback. These now resolve the same way for both call styles.
4648
- **Facade static calls keep concrete method return types.** Static calls on Laravel-style facades now resolve missing methods through `getFacadeAccessor()` and facade `@mixin` targets before falling back to `__callStatic()`, so values like `Driver::details()` keep the concrete provider method return type instead of degrading to the facade's broad magic-call return. Contributed by @calebdw.

src/code_lens.rs

Lines changed: 38 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
//! Code Lens (`textDocument/codeLens`) support.
22
//!
3-
//! Shows clickable annotations above methods that override a parent
4-
//! method or implement an interface method, linking to the prototype
5-
//! declaration.
3+
//! Shows override/implement annotations linking to the prototype declaration.
64
75
use tower_lsp::lsp_types::*;
86

@@ -11,6 +9,17 @@ use crate::definition::member::MemberKind;
119
use crate::text_position::offset_to_position;
1210
use crate::types::{ClassInfo, ClassLikeKind, MAX_INHERITANCE_DEPTH};
1311

12+
fn line_indent(content: &str, byte_offset: usize) -> u32 {
13+
let line_start = content[..byte_offset]
14+
.rfind('\n')
15+
.map(|i| i + 1)
16+
.unwrap_or(0);
17+
content[line_start..]
18+
.chars()
19+
.take_while(|c| *c == ' ' || *c == '\t')
20+
.count() as u32
21+
}
22+
1423
/// Information about a prototype (ancestor) method that a local method
1524
/// overrides or implements.
1625
struct Prototype {
@@ -41,32 +50,28 @@ impl Backend {
4150
let class_fqn = class.fqn();
4251

4352
for method in &class.methods {
44-
// Skip synthetic/stub methods with no real source position.
45-
if method.name_offset == 0 {
46-
continue;
47-
}
48-
49-
// Skip virtual methods (injected via @method tags, not
50-
// actually declared in source).
51-
if method.is_virtual {
53+
if method.name_offset == 0
54+
|| method.is_virtual
55+
|| method.visibility == crate::types::Visibility::Private
56+
{
5257
continue;
5358
}
5459

55-
if let Some(proto) =
56-
self.find_prototype(class, &class_fqn, &method.name, uri, content)
57-
{
58-
let pos = offset_to_position(content, method.name_offset as usize);
59-
let range = Range {
60-
start: Position {
61-
line: pos.line,
62-
character: 0,
63-
},
64-
end: Position {
65-
line: pos.line,
66-
character: 0,
67-
},
68-
};
69-
60+
let pos = offset_to_position(content, method.name_offset as usize);
61+
let indent = line_indent(content, method.name_offset as usize);
62+
let range = Range {
63+
start: Position {
64+
line: pos.line,
65+
character: indent,
66+
},
67+
end: Position {
68+
line: pos.line,
69+
character: indent,
70+
},
71+
};
72+
73+
let proto = self.find_prototype(class, &class_fqn, &method.name, uri, content);
74+
if let Some(proto) = proto {
7075
let icon = if proto.is_interface { "◆" } else { "↑" };
7176
let title = format!("{} {}::{}", icon, proto.ancestor_name, method.name);
7277

@@ -307,39 +312,14 @@ impl Backend {
307312
/// Build the LSP `Command` for a code lens that navigates to a target
308313
/// location.
309314
///
310-
/// Uses `editor.action.showReferences` (widely supported) by default,
311-
/// and `vscode.open` when connected to a VS Code client.
315+
/// Returns a custom command handled by `workspace/executeCommand`,
316+
/// which uses `window/showDocument` to navigate. This works across
317+
/// all LSP clients without editor-specific command sniffing.
312318
fn build_code_lens_command(&self, title: String, uri: Url, position: Position) -> Command {
313-
let client = self.client_name.lock();
314-
if client.contains("Visual Studio Code") {
315-
// VS Code: use vscode.open with a fragment for direct navigation.
316-
let fragment = format!("L{},{}", position.line + 1, position.character + 1);
317-
let mut target_uri = uri;
318-
target_uri.set_fragment(Some(&fragment));
319-
Command {
320-
title,
321-
command: "vscode.open".to_string(),
322-
arguments: Some(vec![serde_json::json!(target_uri)]),
323-
}
324-
} else {
325-
// All other editors: use editor.action.showReferences which is
326-
// handled by most LSP clients (Zed, Neovim, Emacs, etc.).
327-
let location = Location {
328-
uri: uri.clone(),
329-
range: Range {
330-
start: position,
331-
end: position,
332-
},
333-
};
334-
Command {
335-
title,
336-
command: "editor.action.showReferences".to_string(),
337-
arguments: Some(vec![
338-
serde_json::json!(uri),
339-
serde_json::json!(position),
340-
serde_json::json!([location]),
341-
]),
342-
}
319+
Command {
320+
title,
321+
command: "phpantom.navigateToPrototype".to_string(),
322+
arguments: Some(vec![serde_json::json!(uri), serde_json::json!(position)]),
343323
}
344324
}
345325

src/definition/implementation.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ impl Backend {
211211
/// `public function handle()` in a class that implements `Handler`),
212212
/// this finds the interface/abstract method declaration and returns
213213
/// its location.
214-
fn resolve_reverse_implementation(
214+
pub(super) fn resolve_reverse_implementation(
215215
&self,
216216
uri: &str,
217217
content: &str,

src/definition/resolve.rs

Lines changed: 60 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -266,41 +266,48 @@ impl Backend {
266266
.resolve_class_reference(uri, content, name, *is_fqn, cursor_offset)
267267
.map(|loc| vec![loc]),
268268

269-
SymbolKind::ClassDeclaration { name }
270-
| SymbolKind::MemberDeclaration { name, .. }
271-
| SymbolKind::NamespaceDeclaration { name } => {
272-
// The cursor is on a declaration name, so "go to
273-
// definition" has nowhere to jump — we are already at the
274-
// definition. Implement "Declaration or Usages" by
275-
// returning the symbol's usages instead.
276-
//
277-
// VS Code achieves this by detecting "definition ==
278-
// current position" and running Find References itself
279-
// (editor.gotoLocation.alternativeDefinitionCommand), but
280-
// PHPStorm simply navigates to the returned location and
281-
// stops. Returning the usages directly makes the feature
282-
// work uniformly across clients.
283-
let usages = self.find_references(uri, content, position, false);
284-
if let Some(usages) = usages
285-
&& !usages.is_empty()
269+
SymbolKind::MemberDeclaration { name, .. } => {
270+
// If this method/property overrides a parent or implements
271+
// an interface member, jump to the prototype declaration.
272+
let ctx = self.file_context(uri);
273+
let class_loader = self.class_loader(&ctx);
274+
let current_class =
275+
crate::class_lookup::find_class_at_offset(&ctx.classes, cursor_offset);
276+
if let Some(cls) = current_class
277+
&& let Some(locs) =
278+
self.resolve_reverse_implementation(uri, content, cls, name, &class_loader)
279+
&& !locs.is_empty()
286280
{
287-
return Some(usages);
281+
return Some(locs);
288282
}
289283

290-
// No usages found: fall back to the declaration's own
291-
// location so clients that rely on the self-location
292-
// signal (VS Code) still trigger their reference fallback.
293-
let parsed_uri = Url::parse(uri).ok()?;
294-
let start =
295-
crate::text_position::offset_to_position(content, cursor_offset as usize);
296-
let end = crate::text_position::offset_to_position(
297-
content,
298-
cursor_offset as usize + name.len(),
299-
);
300-
Some(vec![Location {
301-
uri: parsed_uri,
302-
range: Range { start, end },
303-
}])
284+
self.declaration_or_usages(uri, content, cursor_offset, name)
285+
}
286+
287+
SymbolKind::ClassDeclaration { name } => {
288+
// If this class extends a parent, jump to the parent
289+
// class declaration.
290+
let ctx = self.file_context(uri);
291+
let current_class =
292+
crate::class_lookup::find_class_at_offset(&ctx.classes, cursor_offset);
293+
if let Some(cls) = current_class
294+
&& let Some(ref parent_name) = cls.parent_class
295+
&& let Some(loc) = self.resolve_class_reference(
296+
uri,
297+
content,
298+
parent_name,
299+
parent_name.contains('\\'),
300+
cursor_offset,
301+
)
302+
{
303+
return Some(vec![loc]);
304+
}
305+
306+
self.declaration_or_usages(uri, content, cursor_offset, name)
307+
}
308+
309+
SymbolKind::NamespaceDeclaration { name } => {
310+
self.declaration_or_usages(uri, content, cursor_offset, name)
304311
}
305312

306313
SymbolKind::FunctionCall { name, .. } => {
@@ -354,6 +361,27 @@ impl Backend {
354361
/// When `is_fqn` is `true`, the name is already fully-qualified
355362
/// (the original PHP source used a leading `\`) and should be used
356363
/// as-is without namespace resolution.
364+
fn declaration_or_usages(
365+
&self,
366+
uri: &str,
367+
content: &str,
368+
cursor_offset: u32,
369+
name: &str,
370+
) -> Option<Vec<Location>> {
371+
// Return the declaration's own location. Editors detect
372+
// "definition == current position" and offer Find References
373+
// as a fallback (e.g. VS Code's
374+
// editor.gotoLocation.alternativeDefinitionCommand).
375+
let parsed_uri = Url::parse(uri).ok()?;
376+
let start = crate::text_position::offset_to_position(content, cursor_offset as usize);
377+
let end =
378+
crate::text_position::offset_to_position(content, cursor_offset as usize + name.len());
379+
Some(vec![Location {
380+
uri: parsed_uri,
381+
range: Range { start, end },
382+
}])
383+
}
384+
357385
pub(super) fn resolve_class_reference(
358386
&self,
359387
uri: &str,

src/server.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,10 @@ impl LanguageServer for Backend {
265265
} else {
266266
None
267267
},
268+
execute_command_provider: Some(ExecuteCommandOptions {
269+
commands: vec!["phpantom.navigateToPrototype".to_string()],
270+
..ExecuteCommandOptions::default()
271+
}),
268272
..ServerCapabilities::default()
269273
},
270274
server_info: Some(ServerInfo {
@@ -1357,6 +1361,31 @@ impl LanguageServer for Backend {
13571361
.await
13581362
}
13591363

1364+
async fn execute_command(
1365+
&self,
1366+
params: ExecuteCommandParams,
1367+
) -> Result<Option<serde_json::Value>> {
1368+
if params.command == "phpantom.navigateToPrototype"
1369+
&& let [uri_val, pos_val] = params.arguments.as_slice()
1370+
&& let Ok(uri) = serde_json::from_value::<Url>(uri_val.clone())
1371+
&& let Ok(position) = serde_json::from_value::<Position>(pos_val.clone())
1372+
&& let Some(ref client) = self.client
1373+
{
1374+
let _ = client
1375+
.show_document(ShowDocumentParams {
1376+
uri,
1377+
external: Some(false),
1378+
take_focus: Some(true),
1379+
selection: Some(Range {
1380+
start: position,
1381+
end: position,
1382+
}),
1383+
})
1384+
.await;
1385+
}
1386+
Ok(None)
1387+
}
1388+
13601389
async fn document_link(&self, params: DocumentLinkParams) -> Result<Option<Vec<DocumentLink>>> {
13611390
let uri = params.text_document.uri.to_string();
13621391
let backend = self.clone_for_blocking();

tests/integration/code_lens.rs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -295,13 +295,13 @@ class Handler extends Base {
295295
let lens = &lenses[0];
296296
// The method `process` in Handler is on line 6 (0-based)
297297
assert_eq!(lens.range.start.line, 6);
298-
assert_eq!(lens.range.start.character, 0);
298+
assert_eq!(lens.range.start.character, 4);
299299
}
300300

301301
// ─── Code Lens Command ─────────────────────────────────────────────────────
302302

303303
#[test]
304-
fn lens_command_uses_show_references_by_default() {
304+
fn lens_command_uses_navigate_to_prototype() {
305305
let backend = create_test_backend();
306306
let content = r#"<?php
307307
class Parent_ {
@@ -317,11 +317,10 @@ class Child extends Parent_ {
317317

318318
assert_eq!(lenses.len(), 1);
319319
let cmd = lenses[0].command.as_ref().unwrap();
320-
assert_eq!(cmd.command, "editor.action.showReferences");
320+
assert_eq!(cmd.command, "phpantom.navigateToPrototype");
321321
assert!(cmd.arguments.is_some());
322322
let args = cmd.arguments.as_ref().unwrap();
323-
// Should have 3 arguments: uri, position, locations[]
324-
assert_eq!(args.len(), 3);
323+
assert_eq!(args.len(), 2);
325324
}
326325

327326
// ─── Multiple Interfaces ────────────────────────────────────────────────────

tests/integration/definition_members.rs

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2597,7 +2597,7 @@ async fn test_goto_definition_method_declaration_returns_self_location() {
25972597
/// Ctrl+Click on a class name at its own declaration site returns the
25982598
/// self-location when the class has no usages, so editors can fall back
25992599
/// to Find References. (When there are usages, they are returned
2600-
/// directly — see `test_goto_definition_interface_declaration_returns_usages`.)
2600+
/// directly — see `test_goto_definition_interface_declaration_returns_own_location`.)
26012601
#[tokio::test]
26022602
async fn test_goto_definition_class_declaration_returns_self_location() {
26032603
let backend = create_test_backend();
@@ -2649,7 +2649,7 @@ async fn test_goto_definition_class_declaration_returns_self_location() {
26492649
/// navigates to the returned location, so we must return the usages
26502650
/// directly rather than the self-location.
26512651
#[tokio::test]
2652-
async fn test_goto_definition_interface_declaration_returns_usages() {
2652+
async fn test_goto_definition_interface_declaration_returns_own_location() {
26532653
let (backend, dir) = create_psr4_workspace(
26542654
r#"{
26552655
"autoload": { "psr-4": { "Test\\": "src/" } }
@@ -2681,9 +2681,7 @@ async fn test_goto_definition_interface_declaration_returns_usages() {
26812681
);
26822682

26832683
let iface_path = dir.path().join("src/EventListenerInterface.php");
2684-
let test_path = dir.path().join("src/Test.php");
26852684
let iface_uri = Url::from_file_path(&iface_path).unwrap();
2686-
let test_uri = Url::from_file_path(&test_path).unwrap();
26872685
let iface_content = std::fs::read_to_string(&iface_path).unwrap();
26882686

26892687
backend
@@ -2697,8 +2695,6 @@ async fn test_goto_definition_interface_declaration_returns_usages() {
26972695
})
26982696
.await;
26992697

2700-
// Click on "EventListenerInterface" in `interface EventListenerInterface`
2701-
// (line 2, inside the name).
27022698
let params = GotoDefinitionParams {
27032699
text_document_position_params: TextDocumentPositionParams {
27042700
text_document: TextDocumentIdentifier {
@@ -2717,16 +2713,13 @@ async fn test_goto_definition_interface_declaration_returns_usages() {
27172713
let locations = match result {
27182714
Some(GotoDefinitionResponse::Array(locs)) => locs,
27192715
Some(GotoDefinitionResponse::Scalar(loc)) => vec![loc],
2720-
other => panic!("Expected usage locations, got: {other:?}"),
2716+
other => panic!("Expected own location, got: {other:?}"),
27212717
};
27222718

2723-
assert!(
2724-
locations.iter().any(|loc| loc.uri == test_uri),
2725-
"Expected the `implements EventListenerInterface` usage in Test.php, got: {locations:#?}"
2726-
);
2727-
assert!(
2728-
!locations.iter().any(|loc| loc.uri == iface_uri),
2729-
"Should not return the declaration's own location when usages exist: {locations:#?}"
2719+
assert_eq!(locations.len(), 1, "should return exactly one location");
2720+
assert_eq!(
2721+
locations[0].uri, iface_uri,
2722+
"should return the declaration's own location so the editor can offer Find References"
27302723
);
27312724
}
27322725

0 commit comments

Comments
 (0)