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, SourceView, str_offset};
7use crate::util::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 let sv = doc.view();
23 if word.starts_with('$') {
24 let bare = word.trim_start_matches('$');
25 let byte_off = sv.byte_of_position(position) as usize;
26 let mut spans = Vec::new();
27 collect_var_refs_in_scope(&doc.program().stmts, bare, byte_off, &mut spans);
28 if let Some(span) = spans.into_iter().min_by_key(|s| s.start) {
29 return Some(Location {
30 uri: uri.clone(),
31 range: Range {
32 start: sv.position_of(span.start),
33 end: sv.position_of(span.end),
34 },
35 });
36 }
37 }
38
39 if let Some(range) = scan_statements(sv, &doc.program().stmts, &word) {
40 return Some(Location {
41 uri: uri.clone(),
42 range,
43 });
44 }
45
46 for (other_uri, other_doc) in other_docs {
47 let other_sv = other_doc.view();
48 if let Some(range) = scan_statements(other_sv, &other_doc.program().stmts, &word) {
49 return Some(Location {
50 uri: other_uri.clone(),
51 range,
52 });
53 }
54 }
55
56 None
57}
58
59pub fn find_declaration_range(_source: &str, doc: &ParsedDoc, name: &str) -> Option<Range> {
62 let sv = doc.view();
63 scan_statements(sv, &doc.program().stmts, name)
64}
65
66fn scan_statements(sv: SourceView<'_>, stmts: &[Stmt<'_, '_>], word: &str) -> Option<Range> {
67 let bare = word.strip_prefix('$').unwrap_or(word);
69 for stmt in stmts {
70 match &stmt.kind {
71 StmtKind::Function(f) if f.name == word => {
72 return Some(sv.name_range(f.name));
73 }
74 StmtKind::Class(c) if c.name == Some(word) => {
75 let name = c.name.expect("match guard ensures Some");
76 return Some(sv.name_range(name));
77 }
78 StmtKind::Class(c) => {
79 for member in c.members.iter() {
80 match &member.kind {
81 ClassMemberKind::Method(m) if m.name == word => {
82 return Some(sv.name_range(m.name));
83 }
84 ClassMemberKind::ClassConst(cc) if cc.name == word => {
85 return Some(sv.name_range(cc.name));
86 }
87 ClassMemberKind::Property(p) if p.name == bare => {
88 return Some(sv.name_range(p.name));
89 }
90 ClassMemberKind::Method(m) if m.name == "__construct" => {
92 for p in m.params.iter() {
93 if p.visibility.is_some() && p.name == bare {
94 return Some(sv.name_range(p.name));
95 }
96 }
97 }
98 _ => {}
99 }
100 }
101 }
102 StmtKind::Interface(i) => {
103 if i.name == word {
104 return Some(sv.name_range(i.name));
105 }
106 for member in i.members.iter() {
107 match &member.kind {
108 ClassMemberKind::Method(m) if m.name == word => {
109 return Some(sv.name_range(m.name));
110 }
111 ClassMemberKind::ClassConst(cc) if cc.name == word => {
112 return Some(sv.name_range(cc.name));
113 }
114 _ => {}
115 }
116 }
117 }
118 StmtKind::Trait(t) => {
119 if t.name == word {
120 return Some(sv.name_range(t.name));
121 }
122 for member in t.members.iter() {
123 match &member.kind {
124 ClassMemberKind::Method(m) if m.name == word => {
125 return Some(sv.name_range(m.name));
126 }
127 ClassMemberKind::ClassConst(cc) if cc.name == word => {
128 return Some(sv.name_range(cc.name));
129 }
130 ClassMemberKind::Property(p) if p.name == bare => {
131 return Some(sv.name_range(p.name));
132 }
133 _ => {}
134 }
135 }
136 }
137 StmtKind::Enum(e) if e.name == word => {
138 return Some(sv.name_range(e.name));
139 }
140 StmtKind::Enum(e) => {
141 for member in e.members.iter() {
142 match &member.kind {
143 EnumMemberKind::Method(m) if m.name == word => {
144 return Some(sv.name_range(m.name));
145 }
146 EnumMemberKind::Case(c) if c.name == word => {
147 return Some(sv.name_range(c.name));
148 }
149 _ => {}
150 }
151 }
152 }
153 StmtKind::Namespace(ns) => {
154 if let NamespaceBody::Braced(inner) = &ns.body
155 && let Some(range) = scan_statements(sv, inner, word)
156 {
157 return Some(range);
158 }
159 }
160 _ => {}
161 }
162 }
163 None
164}
165
166pub fn find_in_indexes(
169 name: &str,
170 indexes: &[(
171 tower_lsp::lsp_types::Url,
172 std::sync::Arc<crate::file_index::FileIndex>,
173 )],
174) -> Option<Location> {
175 let bare = name.strip_prefix('$').unwrap_or(name);
176 for (uri, idx) in indexes {
177 for f in &idx.functions {
179 if f.name == bare || f.name == name {
180 let pos = tower_lsp::lsp_types::Position {
181 line: f.start_line,
182 character: 0,
183 };
184 return Some(Location {
185 uri: uri.clone(),
186 range: Range {
187 start: pos,
188 end: pos,
189 },
190 });
191 }
192 }
193 for cls in &idx.classes {
195 if cls.name == bare || cls.name == name {
196 let pos = tower_lsp::lsp_types::Position {
197 line: cls.start_line,
198 character: 0,
199 };
200 return Some(Location {
201 uri: uri.clone(),
202 range: Range {
203 start: pos,
204 end: pos,
205 },
206 });
207 }
208 for m in &cls.methods {
210 if m.name == name {
211 let pos = tower_lsp::lsp_types::Position {
212 line: m.start_line,
213 character: 0,
214 };
215 return Some(Location {
216 uri: uri.clone(),
217 range: Range {
218 start: pos,
219 end: pos,
220 },
221 });
222 }
223 }
224 for p in &cls.properties {
226 if p.name == bare {
227 let pos = tower_lsp::lsp_types::Position {
228 line: p.start_line,
229 character: 0,
230 };
231 return Some(Location {
232 uri: uri.clone(),
233 range: Range {
234 start: pos,
235 end: pos,
236 },
237 });
238 }
239 }
240 for cc in &cls.constants {
242 if cc.as_str() == name {
243 let pos = tower_lsp::lsp_types::Position {
244 line: cls.start_line,
245 character: 0,
246 };
247 return Some(Location {
248 uri: uri.clone(),
249 range: Range {
250 start: pos,
251 end: pos,
252 },
253 });
254 }
255 }
256 for case in &cls.cases {
258 if case.as_str() == name {
259 let pos = tower_lsp::lsp_types::Position {
260 line: cls.start_line,
261 character: 0,
262 };
263 return Some(Location {
264 uri: uri.clone(),
265 range: Range {
266 start: pos,
267 end: pos,
268 },
269 });
270 }
271 }
272 }
273 }
274 None
275}
276
277fn _name_range_from_offset(sv: SourceView<'_>, name: &str) -> Range {
278 let start_offset = str_offset(sv.source(), name);
279 let start = sv.position_of(start_offset);
280 Range {
281 start,
282 end: Position {
283 line: start.line,
284 character: start.character + name.chars().map(|c| c.len_utf16() as u32).sum::<u32>(),
285 },
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use crate::test_utils::cursor;
293
294 fn uri() -> Url {
295 Url::parse("file:///test.php").unwrap()
296 }
297
298 fn pos(line: u32, character: u32) -> Position {
299 Position { line, character }
300 }
301
302 #[test]
303 fn jumps_to_function_definition() {
304 let (src, p) = cursor("<?php\nfunction g$0reet() {}");
305 let doc = ParsedDoc::parse(src.clone());
306 let result = goto_definition(&uri(), &src, &doc, &[], p);
307 assert!(result.is_some(), "expected a location");
308 let loc = result.unwrap();
309 assert_eq!(loc.range.start.line, 1);
310 assert_eq!(loc.uri, uri());
311 }
312
313 #[test]
314 fn jumps_to_class_definition() {
315 let (src, p) = cursor("<?php\nclass My$0Service {}");
316 let doc = ParsedDoc::parse(src.clone());
317 let result = goto_definition(&uri(), &src, &doc, &[], p);
318 assert!(result.is_some());
319 let loc = result.unwrap();
320 assert_eq!(loc.range.start.line, 1);
321 }
322
323 #[test]
324 fn jumps_to_interface_definition() {
325 let (src, p) = cursor("<?php\ninterface Co$0untable {}");
326 let doc = ParsedDoc::parse(src.clone());
327 let result = goto_definition(&uri(), &src, &doc, &[], p);
328 assert!(result.is_some());
329 assert_eq!(result.unwrap().range.start.line, 1);
330 }
331
332 #[test]
333 fn jumps_to_trait_definition() {
334 let src = "<?php\ntrait Loggable {}";
335 let doc = ParsedDoc::parse(src.to_string());
336 let result = goto_definition(&uri(), src, &doc, &[], pos(1, 8));
337 assert!(result.is_some());
338 assert_eq!(result.unwrap().range.start.line, 1);
339 }
340
341 #[test]
342 fn jumps_to_class_method_definition() {
343 let src = "<?php\nclass Calc { public function add() {} }";
344 let doc = ParsedDoc::parse(src.to_string());
345 let result = goto_definition(&uri(), src, &doc, &[], pos(1, 32));
346 assert!(result.is_some(), "expected location for method 'add'");
347 }
348
349 #[test]
350 fn returns_none_for_unknown_word() {
351 let src = "<?php\necho 'hello';";
352 let doc = ParsedDoc::parse(src.to_string());
353 let result = goto_definition(&uri(), src, &doc, &[], pos(1, 6));
355 assert!(result.is_none());
356 }
357
358 #[test]
359 fn variable_goto_definition_jumps_to_first_occurrence() {
360 let src = "<?php\nfunction foo() {\n $x = 1;\n return $x;\n}";
361 let doc = ParsedDoc::parse(src.to_string());
362 let result = goto_definition(&uri(), src, &doc, &[], pos(3, 12));
364 assert!(result.is_some(), "expected location for $x");
365 let loc = result.unwrap();
366 assert_eq!(
368 loc.range.start.line, 2,
369 "should jump to first $x occurrence"
370 );
371 }
372
373 #[test]
374 fn jumps_to_enum_definition() {
375 let src = "<?php\nenum Suit { case Hearts; }";
376 let doc = ParsedDoc::parse(src.to_string());
377 let result = goto_definition(&uri(), src, &doc, &[], pos(1, 7));
378 assert!(result.is_some(), "expected location for enum 'Suit'");
379 assert_eq!(result.unwrap().range.start.line, 1);
380 }
381
382 #[test]
383 fn jumps_to_enum_case_definition() {
384 let src = "<?php\nenum Suit { case Hearts; case Spades; }";
385 let doc = ParsedDoc::parse(src.to_string());
386 let result = goto_definition(&uri(), src, &doc, &[], pos(1, 22));
387 assert!(result.is_some(), "expected location for enum case 'Hearts'");
388 }
389
390 #[test]
391 fn jumps_to_enum_method_definition() {
392 let src = "<?php\nenum Suit { public function label(): string { return ''; } }";
393 let doc = ParsedDoc::parse(src.to_string());
394 let result = goto_definition(&uri(), src, &doc, &[], pos(1, 30));
395 assert!(
396 result.is_some(),
397 "expected location for enum method 'label'"
398 );
399 }
400
401 #[test]
402 fn jumps_to_symbol_inside_namespace() {
403 let src = "<?php\nnamespace App {\nfunction boot() {}\n}";
404 let doc = ParsedDoc::parse(src.to_string());
405 let result = goto_definition(&uri(), src, &doc, &[], pos(2, 10));
406 assert!(result.is_some());
407 assert_eq!(result.unwrap().range.start.line, 2);
408 }
409
410 #[test]
411 fn finds_class_definition_in_other_document() {
412 let current_src = "<?php\n$s = new MyService();";
413 let current_doc = ParsedDoc::parse(current_src.to_string());
414 let other_src = "<?php\nclass MyService {}";
415 let other_uri = Url::parse("file:///other.php").unwrap();
416 let other_doc = Arc::new(ParsedDoc::parse(other_src.to_string()));
417
418 let result = goto_definition(
419 &uri(),
420 current_src,
421 ¤t_doc,
422 &[(other_uri.clone(), other_doc)],
423 pos(1, 13),
424 );
425 assert!(result.is_some(), "expected cross-file location");
426 assert_eq!(result.unwrap().uri, other_uri);
427 }
428
429 #[test]
430 fn finds_function_definition_in_other_document() {
431 let current_src = "<?php\nhelperFn();";
432 let current_doc = ParsedDoc::parse(current_src.to_string());
433 let other_src = "<?php\nfunction helperFn() {}";
434 let other_uri = Url::parse("file:///helpers.php").unwrap();
435 let other_doc = Arc::new(ParsedDoc::parse(other_src.to_string()));
436
437 let result = goto_definition(
438 &uri(),
439 current_src,
440 ¤t_doc,
441 &[(other_uri.clone(), other_doc)],
442 pos(1, 3),
443 );
444 assert!(
445 result.is_some(),
446 "expected cross-file location for helperFn"
447 );
448 assert_eq!(result.unwrap().uri, other_uri);
449 }
450
451 #[test]
452 fn current_file_takes_priority_over_other_docs() {
453 let src = "<?php\nclass Foo {}";
454 let doc = ParsedDoc::parse(src.to_string());
455 let other_src = "<?php\nclass Foo {}";
456 let other_uri = Url::parse("file:///other.php").unwrap();
457 let other_doc = Arc::new(ParsedDoc::parse(other_src.to_string()));
458
459 let result = goto_definition(&uri(), src, &doc, &[(other_uri, other_doc)], pos(1, 8));
460 assert_eq!(result.unwrap().uri, uri(), "should prefer current file");
461 }
462
463 #[test]
464 fn goto_definition_class_constant() {
465 let src = "<?php\nclass MyClass { const STATUS_OK = 1; }";
469 let doc = ParsedDoc::parse(src.to_string());
470 let result = goto_definition(&uri(), src, &doc, &[], pos(1, 22));
472 assert!(
473 result.is_some(),
474 "expected a location for class constant STATUS_OK"
475 );
476 let loc = result.unwrap();
477 assert_eq!(
478 loc.range.start.line, 1,
479 "should jump to line 1 where the constant is declared"
480 );
481 assert_eq!(loc.uri, uri(), "should be in the same file");
482 }
483
484 #[test]
485 fn goto_definition_property() {
486 let src = "<?php\nclass Person { public string $name; }";
492 let doc = ParsedDoc::parse(src.to_string());
493 let result = goto_definition(&uri(), src, &doc, &[], pos(1, 30));
495 assert!(
496 result.is_some(),
497 "expected a location for property '$name', cursor at column 30"
498 );
499 let loc = result.unwrap();
500 assert_eq!(
501 loc.range.start.line, 1,
502 "should jump to line 1 where the property is declared"
503 );
504 assert_eq!(loc.uri, uri(), "should be in the same file");
505 }
506
507 #[test]
508 fn jumps_to_trait_method_definition() {
509 let src = "<?php\ntrait Greeting {\n public function sayHello(string $name): string { return ''; }\n}";
510 let doc = ParsedDoc::parse(src.to_string());
511 let result = goto_definition(&uri(), src, &doc, &[], pos(2, 22));
512 assert!(
513 result.is_some(),
514 "expected location for trait method 'sayHello'"
515 );
516 assert_eq!(result.unwrap().range.start.line, 2);
517 }
518}