Skip to content

Commit fca2d47

Browse files
committed
Implement goto definition
1 parent 552f0b6 commit fca2d47

6 files changed

Lines changed: 1452 additions & 6 deletions

File tree

src/completion/builder.rs

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -206,12 +206,7 @@ impl Backend {
206206
.as_deref()
207207
.unwrap_or(&a.label)
208208
.to_lowercase()
209-
.cmp(
210-
&b.filter_text
211-
.as_deref()
212-
.unwrap_or(&b.label)
213-
.to_lowercase(),
214-
)
209+
.cmp(&b.filter_text.as_deref().unwrap_or(&b.label).to_lowercase())
215210
});
216211

217212
for (i, item) in items.iter_mut().enumerate() {

src/definition/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/// Goto definition support.
2+
///
3+
/// This module contains the logic for resolving "go to definition" requests,
4+
/// allowing users to jump from a class/interface/trait/enum name reference
5+
/// to its definition in the source code.
6+
///
7+
/// - [`resolve`]: Word extraction, name resolution, and definition location lookup.
8+
mod resolve;

src/definition/resolve.rs

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
/// Goto-definition resolution.
2+
///
3+
/// Given a cursor position in a PHP file this module:
4+
/// 1. Extracts the symbol (class / interface / trait / enum name) under the cursor.
5+
/// 2. Resolves it to a fully-qualified name using the file's `use` map and namespace.
6+
/// 3. Locates the file on disk via PSR-4 mappings.
7+
/// 4. Finds the exact line of the symbol's declaration inside that file.
8+
/// 5. Returns an LSP `Location` the editor can jump to.
9+
use tower_lsp::lsp_types::*;
10+
11+
use crate::Backend;
12+
use crate::composer;
13+
14+
impl Backend {
15+
/// Handle a "go to definition" request.
16+
///
17+
/// Returns `Some(Location)` when the symbol under the cursor can be
18+
/// resolved to a file and a position inside that file, or `None` when
19+
/// resolution fails at any step.
20+
pub(crate) fn resolve_definition(
21+
&self,
22+
uri: &str,
23+
content: &str,
24+
position: Position,
25+
) -> Option<Location> {
26+
// 1. Extract the symbol name under the cursor.
27+
let word = Self::extract_word_at_position(content, position)?;
28+
29+
if word.is_empty() {
30+
return None;
31+
}
32+
33+
// 2. Gather context from the current file (use map + namespace).
34+
let file_use_map = self
35+
.use_map
36+
.lock()
37+
.ok()
38+
.and_then(|map| map.get(uri).cloned())
39+
.unwrap_or_default();
40+
41+
let file_namespace = self
42+
.namespace_map
43+
.lock()
44+
.ok()
45+
.and_then(|map| map.get(uri).cloned())
46+
.flatten();
47+
48+
// 3. Resolve to a fully-qualified name.
49+
let fqn = Self::resolve_to_fqn(&word, &file_use_map, &file_namespace);
50+
51+
// Build a list of FQN candidates to try. The resolved name is tried
52+
// first, but when the original word already contains `\` (e.g. from a
53+
// `use` statement where the name is already fully-qualified) we also
54+
// try the raw word so we don't fail just because namespace-prefixing
55+
// produced a wrong result.
56+
let mut candidates = vec![fqn];
57+
if word.contains('\\') && !candidates.contains(&word) {
58+
candidates.push(word.clone());
59+
}
60+
61+
// 4. Try to find the class in the current file first (same-file jump).
62+
for fqn in &candidates {
63+
if let Some(location) = self.find_definition_in_ast_map(fqn, content, uri) {
64+
return Some(location);
65+
}
66+
}
67+
68+
// 5. Resolve file path via PSR-4.
69+
let workspace_root = self
70+
.workspace_root
71+
.lock()
72+
.ok()
73+
.and_then(|guard| guard.clone())?;
74+
75+
let mappings = self.psr4_mappings.lock().ok()?;
76+
77+
for fqn in &candidates {
78+
if let Some(file_path) = composer::resolve_class_path(&mappings, &workspace_root, fqn) {
79+
// 6. Read the target file and find the definition line.
80+
if let Some(target_content) = std::fs::read_to_string(&file_path).ok() {
81+
let short_name = fqn.rsplit('\\').next().unwrap_or(fqn);
82+
if let Some(target_position) =
83+
Self::find_definition_position(&target_content, short_name)
84+
{
85+
if let Some(target_uri) = Url::from_file_path(&file_path).ok() {
86+
return Some(Location {
87+
uri: target_uri,
88+
range: Range {
89+
start: target_position,
90+
end: target_position,
91+
},
92+
});
93+
}
94+
}
95+
}
96+
}
97+
}
98+
99+
None
100+
}
101+
102+
/// Extract the symbol name (class / interface / trait / enum / namespace)
103+
/// at the given cursor position.
104+
///
105+
/// The word is defined as a contiguous run of alphanumeric characters,
106+
/// underscores, and backslashes (to capture fully-qualified names).
107+
pub fn extract_word_at_position(content: &str, position: Position) -> Option<String> {
108+
let lines: Vec<&str> = content.lines().collect();
109+
let line_idx = position.line as usize;
110+
if line_idx >= lines.len() {
111+
return None;
112+
}
113+
114+
let line = lines[line_idx];
115+
let chars: Vec<char> = line.chars().collect();
116+
let col = (position.character as usize).min(chars.len());
117+
118+
// Nothing to do on an empty line or if cursor is at position 0
119+
// with no word character.
120+
if chars.is_empty() {
121+
return None;
122+
}
123+
124+
// If the cursor is right after a word (col points at a non-word char
125+
// or end-of-line), we still want to resolve the word to its left.
126+
// But if the cursor is in the middle of a word, expand in both
127+
// directions.
128+
129+
let is_word_char = |c: char| c.is_alphanumeric() || c == '_' || c == '\\';
130+
131+
// Find the start of the word: walk left from cursor.
132+
let mut start = col;
133+
134+
// If cursor is between two chars and the right one is a word char,
135+
// start there. Otherwise start from the char to the left.
136+
if start < chars.len() && is_word_char(chars[start]) {
137+
// cursor is on a word char — expand left
138+
} else if start > 0 && is_word_char(chars[start - 1]) {
139+
start -= 1;
140+
} else {
141+
return None;
142+
}
143+
144+
// Walk left to find start of word
145+
while start > 0 && is_word_char(chars[start - 1]) {
146+
start -= 1;
147+
}
148+
149+
// Walk right to find end of word
150+
let mut end = col;
151+
if end < chars.len() && is_word_char(chars[end]) {
152+
// cursor is on a word char — also expand right
153+
while end < chars.len() && is_word_char(chars[end]) {
154+
end += 1;
155+
}
156+
} else {
157+
// Cursor was past the word — expand right from start
158+
end = start;
159+
while end < chars.len() && is_word_char(chars[end]) {
160+
end += 1;
161+
}
162+
}
163+
164+
if start == end {
165+
return None;
166+
}
167+
168+
let word: String = chars[start..end].iter().collect();
169+
170+
// Strip a leading `\` (PHP fully-qualified prefix).
171+
let word = word.strip_prefix('\\').unwrap_or(&word).to_string();
172+
173+
// Strip trailing `\` if any (partial namespace).
174+
let word = word.strip_suffix('\\').unwrap_or(&word).to_string();
175+
176+
if word.is_empty() {
177+
return None;
178+
}
179+
180+
Some(word)
181+
}
182+
183+
/// Resolve a short or partially-qualified name to a fully-qualified name
184+
/// using the file's `use` map and namespace context.
185+
///
186+
/// Rules:
187+
/// - If the name contains `\` it is already (partially) qualified.
188+
/// Check if the first segment is in the use_map; if so, expand it.
189+
/// Otherwise prefix with the current namespace.
190+
/// - If the name is unqualified (no `\`):
191+
/// 1. Check the use_map for a direct mapping.
192+
/// 2. Prefix with the current namespace.
193+
/// 3. Fall back to the bare name (global namespace).
194+
pub fn resolve_to_fqn(
195+
name: &str,
196+
use_map: &std::collections::HashMap<String, String>,
197+
namespace: &Option<String>,
198+
) -> String {
199+
// Already fully-qualified (leading `\` was stripped earlier).
200+
// If name contains `\`, check if the first segment is aliased.
201+
if name.contains('\\') {
202+
let first_segment = name.split('\\').next().unwrap_or(name);
203+
if let Some(fqn_prefix) = use_map.get(first_segment) {
204+
// Replace the first segment with the FQN prefix.
205+
let rest = &name[first_segment.len()..];
206+
return format!("{}{}", fqn_prefix, rest);
207+
}
208+
// Not in use map — might already be fully-qualified, or
209+
// needs current namespace prepended.
210+
if let Some(ns) = namespace {
211+
return format!("{}\\{}", ns, name);
212+
}
213+
return name.to_string();
214+
}
215+
216+
// Unqualified name — try use_map first.
217+
if let Some(fqn) = use_map.get(name) {
218+
return fqn.clone();
219+
}
220+
221+
// Try current namespace.
222+
if let Some(ns) = namespace {
223+
return format!("{}\\{}", ns, name);
224+
}
225+
226+
// Fall back to global / bare name.
227+
name.to_string()
228+
}
229+
230+
/// Try to find the definition of a class in the current file by checking
231+
/// the ast_map.
232+
fn find_definition_in_ast_map(&self, fqn: &str, content: &str, uri: &str) -> Option<Location> {
233+
let short_name = fqn.rsplit('\\').next().unwrap_or(fqn);
234+
235+
let classes = self
236+
.ast_map
237+
.lock()
238+
.ok()
239+
.and_then(|map| map.get(uri).cloned())?;
240+
241+
let _class_info = classes.iter().find(|c| c.name == short_name)?;
242+
243+
// Convert start_offset to a position. start_offset is the opening
244+
// brace — scan backwards to find the class/interface keyword line.
245+
let position = Self::find_definition_position(content, short_name)?;
246+
247+
// Build a file URI from the current URI string.
248+
let parsed_uri = Url::parse(uri).ok()?;
249+
250+
Some(Location {
251+
uri: parsed_uri,
252+
range: Range {
253+
start: position,
254+
end: position,
255+
},
256+
})
257+
}
258+
259+
/// Find the position (line, character) of a class / interface / trait / enum
260+
/// declaration inside the given file content.
261+
///
262+
/// Searches for patterns like:
263+
/// `class ClassName`
264+
/// `interface ClassName`
265+
/// `trait ClassName`
266+
/// `enum ClassName`
267+
/// `abstract class ClassName`
268+
/// `final class ClassName`
269+
/// `readonly class ClassName`
270+
///
271+
/// Returns the position of the keyword (`class`, `interface`, etc.) on
272+
/// the matching line.
273+
pub fn find_definition_position(content: &str, class_name: &str) -> Option<Position> {
274+
let keywords = ["class", "interface", "trait", "enum"];
275+
276+
for (line_idx, line) in content.lines().enumerate() {
277+
for keyword in &keywords {
278+
// Search for `keyword ClassName` making sure ClassName is
279+
// followed by a word boundary (whitespace, `{`, `:`, end of
280+
// line) so we don't match partial names.
281+
let pattern = format!("{} {}", keyword, class_name);
282+
if let Some(col) = line.find(&pattern) {
283+
// Verify word boundary before the keyword: either start
284+
// of line or preceded by whitespace / non-alphanumeric.
285+
let before_ok = col == 0 || {
286+
let prev = line.as_bytes().get(col - 1).copied().unwrap_or(b' ');
287+
!(prev as char).is_alphanumeric() && prev != b'_'
288+
};
289+
290+
// Verify word boundary after the class name.
291+
let after_pos = col + pattern.len();
292+
let after_ok = after_pos >= line.len() || {
293+
let next = line.as_bytes().get(after_pos).copied().unwrap_or(b' ');
294+
!(next as char).is_alphanumeric() && next != b'_'
295+
};
296+
297+
if before_ok && after_ok {
298+
return Some(Position {
299+
line: line_idx as u32,
300+
character: col as u32,
301+
});
302+
}
303+
}
304+
}
305+
}
306+
307+
None
308+
}
309+
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use tower_lsp::Client;
1919

2020
mod completion;
2121
pub mod composer;
22+
mod definition;
2223
mod parser;
2324
mod server;
2425
pub mod types;

src/server.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ impl LanguageServer for Backend {
4545
text_document_sync: Some(TextDocumentSyncCapability::Kind(
4646
TextDocumentSyncKind::FULL,
4747
)),
48+
definition_provider: Some(OneOf::Left(true)),
4849
..ServerCapabilities::default()
4950
},
5051
server_info: Some(ServerInfo {
@@ -144,6 +145,32 @@ impl LanguageServer for Backend {
144145
.await;
145146
}
146147

148+
async fn goto_definition(
149+
&self,
150+
params: GotoDefinitionParams,
151+
) -> Result<Option<GotoDefinitionResponse>> {
152+
let uri = params
153+
.text_document_position_params
154+
.text_document
155+
.uri
156+
.to_string();
157+
let position = params.text_document_position_params.position;
158+
159+
let content = if let Ok(files) = self.open_files.lock() {
160+
files.get(&uri).cloned()
161+
} else {
162+
None
163+
};
164+
165+
if let Some(content) = content {
166+
if let Some(location) = self.resolve_definition(&uri, &content, position) {
167+
return Ok(Some(GotoDefinitionResponse::Scalar(location)));
168+
}
169+
}
170+
171+
Ok(None)
172+
}
173+
147174
async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
148175
let uri = params.text_document_position.text_document.uri.to_string();
149176
let position = params.text_document_position.position;

0 commit comments

Comments
 (0)