1use std::sync::Arc;
2
3use php_ast::{ClassMemberKind, EnumMemberKind, NamespaceBody, Stmt, StmtKind};
4use tower_lsp::lsp_types::{Location, Position, Range, Url};
5
6use crate::ast::{ParsedDoc, name_range, offset_to_position, str_offset};
7use crate::util::{utf16_pos_to_byte, word_at};
8use crate::walk::collect_var_refs_in_scope;
9
10pub fn goto_definition(
13 uri: &Url,
14 source: &str,
15 doc: &ParsedDoc,
16 other_docs: &[(Url, Arc<ParsedDoc>)],
17 position: Position,
18) -> Option<Location> {
19 let word = word_at(source, position)?;
20
21 if word.starts_with('$') {
23 let bare = word.trim_start_matches('$');
24 let byte_off = utf16_pos_to_byte(source, position);
25 let mut spans = Vec::new();
26 collect_var_refs_in_scope(&doc.program().stmts, bare, byte_off, &mut spans);
27 if let Some(span) = spans.into_iter().min_by_key(|s| s.start) {
28 return Some(Location {
29 uri: uri.clone(),
30 range: Range {
31 start: offset_to_position(source, span.start),
32 end: offset_to_position(source, span.end),
33 },
34 });
35 }
36 }
37
38 if let Some(range) = scan_statements(source, &doc.program().stmts, &word) {
39 return Some(Location {
40 uri: uri.clone(),
41 range,
42 });
43 }
44
45 for (other_uri, other_doc) in other_docs {
46 let other_source = other_doc.source();
47 if let Some(range) = scan_statements(other_source, &other_doc.program().stmts, &word) {
48 return Some(Location {
49 uri: other_uri.clone(),
50 range,
51 });
52 }
53 }
54
55 None
56}
57
58pub fn find_declaration_range(source: &str, doc: &ParsedDoc, name: &str) -> Option<Range> {
61 scan_statements(source, &doc.program().stmts, name)
62}
63
64fn scan_statements(source: &str, stmts: &[Stmt<'_, '_>], word: &str) -> Option<Range> {
65 let bare = word.strip_prefix('$').unwrap_or(word);
67 for stmt in stmts {
68 match &stmt.kind {
69 StmtKind::Function(f) if f.name == word => {
70 return Some(name_range(source, f.name));
71 }
72 StmtKind::Class(c) if c.name == Some(word) => {
73 let name = c.name.expect("match guard ensures Some");
74 return Some(name_range(source, name));
75 }
76 StmtKind::Class(c) => {
77 for member in c.members.iter() {
78 match &member.kind {
79 ClassMemberKind::Method(m) if m.name == word => {
80 return Some(name_range(source, m.name));
81 }
82 ClassMemberKind::ClassConst(cc) if cc.name == word => {
83 return Some(name_range(source, cc.name));
84 }
85 ClassMemberKind::Property(p) if p.name == bare => {
86 return Some(name_range(source, p.name));
87 }
88 _ => {}
89 }
90 }
91 }
92 StmtKind::Interface(i) if i.name == word => {
93 return Some(name_range(source, i.name));
94 }
95 StmtKind::Trait(t) if t.name == word => {
96 return Some(name_range(source, t.name));
97 }
98 StmtKind::Enum(e) if e.name == word => {
99 return Some(name_range(source, e.name));
100 }
101 StmtKind::Enum(e) => {
102 for member in e.members.iter() {
103 match &member.kind {
104 EnumMemberKind::Method(m) if m.name == word => {
105 return Some(name_range(source, m.name));
106 }
107 EnumMemberKind::Case(c) if c.name == word => {
108 return Some(name_range(source, c.name));
109 }
110 _ => {}
111 }
112 }
113 }
114 StmtKind::Namespace(ns) => {
115 if let NamespaceBody::Braced(inner) = &ns.body
116 && let Some(range) = scan_statements(source, inner, word)
117 {
118 return Some(range);
119 }
120 }
121 _ => {}
122 }
123 }
124 None
125}
126
127fn _name_range_from_offset(source: &str, name: &str) -> Range {
128 let start_offset = str_offset(source, name);
129 let start = offset_to_position(source, start_offset);
130 Range {
131 start,
132 end: Position {
133 line: start.line,
134 character: start.character + name.chars().map(|c| c.len_utf16() as u32).sum::<u32>(),
135 },
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142 use crate::test_utils::cursor;
143
144 fn uri() -> Url {
145 Url::parse("file:///test.php").unwrap()
146 }
147
148 fn pos(line: u32, character: u32) -> Position {
149 Position { line, character }
150 }
151
152 #[test]
153 fn jumps_to_function_definition() {
154 let (src, p) = cursor("<?php\nfunction g$0reet() {}");
155 let doc = ParsedDoc::parse(src.clone());
156 let result = goto_definition(&uri(), &src, &doc, &[], p);
157 assert!(result.is_some(), "expected a location");
158 let loc = result.unwrap();
159 assert_eq!(loc.range.start.line, 1);
160 assert_eq!(loc.uri, uri());
161 }
162
163 #[test]
164 fn jumps_to_class_definition() {
165 let (src, p) = cursor("<?php\nclass My$0Service {}");
166 let doc = ParsedDoc::parse(src.clone());
167 let result = goto_definition(&uri(), &src, &doc, &[], p);
168 assert!(result.is_some());
169 let loc = result.unwrap();
170 assert_eq!(loc.range.start.line, 1);
171 }
172
173 #[test]
174 fn jumps_to_interface_definition() {
175 let (src, p) = cursor("<?php\ninterface Co$0untable {}");
176 let doc = ParsedDoc::parse(src.clone());
177 let result = goto_definition(&uri(), &src, &doc, &[], p);
178 assert!(result.is_some());
179 assert_eq!(result.unwrap().range.start.line, 1);
180 }
181
182 #[test]
183 fn jumps_to_trait_definition() {
184 let src = "<?php\ntrait Loggable {}";
185 let doc = ParsedDoc::parse(src.to_string());
186 let result = goto_definition(&uri(), src, &doc, &[], pos(1, 8));
187 assert!(result.is_some());
188 assert_eq!(result.unwrap().range.start.line, 1);
189 }
190
191 #[test]
192 fn jumps_to_class_method_definition() {
193 let src = "<?php\nclass Calc { public function add() {} }";
194 let doc = ParsedDoc::parse(src.to_string());
195 let result = goto_definition(&uri(), src, &doc, &[], pos(1, 32));
196 assert!(result.is_some(), "expected location for method 'add'");
197 }
198
199 #[test]
200 fn returns_none_for_unknown_word() {
201 let src = "<?php\necho 'hello';";
202 let doc = ParsedDoc::parse(src.to_string());
203 let result = goto_definition(&uri(), src, &doc, &[], pos(1, 6));
205 assert!(result.is_none());
206 }
207
208 #[test]
209 fn variable_goto_definition_jumps_to_first_occurrence() {
210 let src = "<?php\nfunction foo() {\n $x = 1;\n return $x;\n}";
211 let doc = ParsedDoc::parse(src.to_string());
212 let result = goto_definition(&uri(), src, &doc, &[], pos(3, 12));
214 assert!(result.is_some(), "expected location for $x");
215 let loc = result.unwrap();
216 assert_eq!(
218 loc.range.start.line, 2,
219 "should jump to first $x occurrence"
220 );
221 }
222
223 #[test]
224 fn jumps_to_enum_definition() {
225 let src = "<?php\nenum Suit { case Hearts; }";
226 let doc = ParsedDoc::parse(src.to_string());
227 let result = goto_definition(&uri(), src, &doc, &[], pos(1, 7));
228 assert!(result.is_some(), "expected location for enum 'Suit'");
229 assert_eq!(result.unwrap().range.start.line, 1);
230 }
231
232 #[test]
233 fn jumps_to_enum_case_definition() {
234 let src = "<?php\nenum Suit { case Hearts; case Spades; }";
235 let doc = ParsedDoc::parse(src.to_string());
236 let result = goto_definition(&uri(), src, &doc, &[], pos(1, 22));
237 assert!(result.is_some(), "expected location for enum case 'Hearts'");
238 }
239
240 #[test]
241 fn jumps_to_enum_method_definition() {
242 let src = "<?php\nenum Suit { public function label(): string { return ''; } }";
243 let doc = ParsedDoc::parse(src.to_string());
244 let result = goto_definition(&uri(), src, &doc, &[], pos(1, 30));
245 assert!(
246 result.is_some(),
247 "expected location for enum method 'label'"
248 );
249 }
250
251 #[test]
252 fn jumps_to_symbol_inside_namespace() {
253 let src = "<?php\nnamespace App {\nfunction boot() {}\n}";
254 let doc = ParsedDoc::parse(src.to_string());
255 let result = goto_definition(&uri(), src, &doc, &[], pos(2, 10));
256 assert!(result.is_some());
257 assert_eq!(result.unwrap().range.start.line, 2);
258 }
259
260 #[test]
261 fn finds_class_definition_in_other_document() {
262 let current_src = "<?php\n$s = new MyService();";
263 let current_doc = ParsedDoc::parse(current_src.to_string());
264 let other_src = "<?php\nclass MyService {}";
265 let other_uri = Url::parse("file:///other.php").unwrap();
266 let other_doc = Arc::new(ParsedDoc::parse(other_src.to_string()));
267
268 let result = goto_definition(
269 &uri(),
270 current_src,
271 ¤t_doc,
272 &[(other_uri.clone(), other_doc)],
273 pos(1, 13),
274 );
275 assert!(result.is_some(), "expected cross-file location");
276 assert_eq!(result.unwrap().uri, other_uri);
277 }
278
279 #[test]
280 fn finds_function_definition_in_other_document() {
281 let current_src = "<?php\nhelperFn();";
282 let current_doc = ParsedDoc::parse(current_src.to_string());
283 let other_src = "<?php\nfunction helperFn() {}";
284 let other_uri = Url::parse("file:///helpers.php").unwrap();
285 let other_doc = Arc::new(ParsedDoc::parse(other_src.to_string()));
286
287 let result = goto_definition(
288 &uri(),
289 current_src,
290 ¤t_doc,
291 &[(other_uri.clone(), other_doc)],
292 pos(1, 3),
293 );
294 assert!(
295 result.is_some(),
296 "expected cross-file location for helperFn"
297 );
298 assert_eq!(result.unwrap().uri, other_uri);
299 }
300
301 #[test]
302 fn current_file_takes_priority_over_other_docs() {
303 let src = "<?php\nclass Foo {}";
304 let doc = ParsedDoc::parse(src.to_string());
305 let other_src = "<?php\nclass Foo {}";
306 let other_uri = Url::parse("file:///other.php").unwrap();
307 let other_doc = Arc::new(ParsedDoc::parse(other_src.to_string()));
308
309 let result = goto_definition(&uri(), src, &doc, &[(other_uri, other_doc)], pos(1, 8));
310 assert_eq!(result.unwrap().uri, uri(), "should prefer current file");
311 }
312
313 #[test]
314 fn goto_definition_class_constant() {
315 let src = "<?php\nclass MyClass { const STATUS_OK = 1; }";
319 let doc = ParsedDoc::parse(src.to_string());
320 let result = goto_definition(&uri(), src, &doc, &[], pos(1, 22));
322 assert!(
323 result.is_some(),
324 "expected a location for class constant STATUS_OK"
325 );
326 let loc = result.unwrap();
327 assert_eq!(
328 loc.range.start.line, 1,
329 "should jump to line 1 where the constant is declared"
330 );
331 assert_eq!(loc.uri, uri(), "should be in the same file");
332 }
333
334 #[test]
335 fn goto_definition_property() {
336 let src = "<?php\nclass Person { public string $name; }";
342 let doc = ParsedDoc::parse(src.to_string());
343 let result = goto_definition(&uri(), src, &doc, &[], pos(1, 30));
345 assert!(
346 result.is_some(),
347 "expected a location for property '$name', cursor at column 30"
348 );
349 let loc = result.unwrap();
350 assert_eq!(
351 loc.range.start.line, 1,
352 "should jump to line 1 where the property is declared"
353 );
354 assert_eq!(loc.uri, uri(), "should be in the same file");
355 }
356}