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::{strip_variable_sigil, utf16_code_units, word_at_position};
8use crate::walk::collect_var_refs_in_scope;
9
10fn zero_width_location(uri: &Url, line: u32) -> Location {
11 let pos = Position { line, character: 0 };
12 Location {
13 uri: uri.clone(),
14 range: Range {
15 start: pos,
16 end: pos,
17 },
18 }
19}
20
21pub fn goto_definition(
24 uri: &Url,
25 source: &str,
26 doc: &ParsedDoc,
27 other_docs: &[(Url, Arc<ParsedDoc>)],
28 position: Position,
29) -> Option<Location> {
30 let word = word_at_position(source, position)?;
31
32 let sv = doc.view();
34 if word.starts_with('$') {
35 let bare = word.trim_start_matches('$');
36 let byte_off = sv.byte_of_position(position) as usize;
37 let mut spans = Vec::new();
38 collect_var_refs_in_scope(&doc.program().stmts, bare, byte_off, &mut spans);
39 if let Some((span, _)) = spans.into_iter().min_by_key(|(s, _)| s.start) {
40 return Some(Location {
41 uri: uri.clone(),
42 range: Range {
43 start: sv.position_of(span.start),
44 end: sv.position_of(span.end),
45 },
46 });
47 }
48 }
49
50 if let Some(range) = scan_statements(sv, &doc.program().stmts, &word) {
51 return Some(Location {
52 uri: uri.clone(),
53 range,
54 });
55 }
56
57 for (other_uri, other_doc) in other_docs {
58 let other_sv = other_doc.view();
59 if let Some(range) = scan_statements(other_sv, &other_doc.program().stmts, &word) {
60 return Some(Location {
61 uri: other_uri.clone(),
62 range,
63 });
64 }
65 }
66
67 None
68}
69
70pub fn find_declaration_range(_source: &str, doc: &ParsedDoc, name: &str) -> Option<Range> {
73 let sv = doc.view();
74 scan_statements(sv, &doc.program().stmts, name)
75}
76
77fn scan_statements(sv: SourceView<'_>, stmts: &[Stmt<'_, '_>], word: &str) -> Option<Range> {
78 let bare = strip_variable_sigil(word);
80 for stmt in stmts {
81 match &stmt.kind {
82 StmtKind::Function(f) if f.name == word => {
83 return Some(sv.name_range(&f.name.to_string()));
84 }
85 StmtKind::Class(c)
86 if c.name.as_ref().map(|n| n.to_string()) == Some(word.to_string()) =>
87 {
88 let name = c.name.expect("match guard ensures Some");
89 return Some(sv.name_range(&name.to_string()));
90 }
91 StmtKind::Class(c) => {
92 for member in c.members.iter() {
93 match &member.kind {
94 ClassMemberKind::Method(m) if m.name == word => {
95 return Some(sv.name_range_in_span(&m.name.to_string(), member.span));
96 }
97 ClassMemberKind::ClassConst(cc) if cc.name == word => {
98 return Some(sv.name_range_in_span(&cc.name.to_string(), member.span));
99 }
100 ClassMemberKind::Property(p) if p.name == bare => {
101 return Some(sv.name_range_in_span(&p.name.to_string(), member.span));
102 }
103 ClassMemberKind::Method(m) if m.name == "__construct" => {
105 for p in m.params.iter() {
106 if p.visibility.is_some() && p.name == bare {
107 return Some(
108 sv.name_range_in_span(&p.name.to_string(), p.span),
109 );
110 }
111 }
112 }
113 _ => {}
114 }
115 }
116 }
117 StmtKind::Interface(i) => {
118 if i.name == word {
119 return Some(sv.name_range(&i.name.to_string()));
120 }
121 for member in i.members.iter() {
122 match &member.kind {
123 ClassMemberKind::Method(m) if m.name == word => {
124 return Some(sv.name_range(&m.name.to_string()));
125 }
126 ClassMemberKind::ClassConst(cc) if cc.name == word => {
127 return Some(sv.name_range(&cc.name.to_string()));
128 }
129 _ => {}
130 }
131 }
132 }
133 StmtKind::Trait(t) => {
134 if t.name == word {
135 return Some(sv.name_range(&t.name.to_string()));
136 }
137 for member in t.members.iter() {
138 match &member.kind {
139 ClassMemberKind::Method(m) if m.name == word => {
140 return Some(sv.name_range(&m.name.to_string()));
141 }
142 ClassMemberKind::ClassConst(cc) if cc.name == word => {
143 return Some(sv.name_range(&cc.name.to_string()));
144 }
145 ClassMemberKind::Property(p) if p.name == bare => {
146 return Some(sv.name_range(&p.name.to_string()));
147 }
148 _ => {}
149 }
150 }
151 }
152 StmtKind::Enum(e) if e.name == word => {
153 return Some(sv.name_range(&e.name.to_string()));
154 }
155 StmtKind::Enum(e) => {
156 for member in e.members.iter() {
157 match &member.kind {
158 EnumMemberKind::Method(m) if m.name == word => {
159 return Some(sv.name_range(&m.name.to_string()));
160 }
161 EnumMemberKind::Case(c) if c.name == word => {
162 return Some(sv.name_range(&c.name.to_string()));
163 }
164 _ => {}
165 }
166 }
167 }
168 StmtKind::Namespace(ns) => {
169 if let NamespaceBody::Braced(inner) = &ns.body
170 && let Some(range) = scan_statements(sv, inner, word)
171 {
172 return Some(range);
173 }
174 }
175 _ => {}
176 }
177 }
178 None
179}
180
181pub fn find_in_indexes(
184 name: &str,
185 indexes: &[(
186 tower_lsp::lsp_types::Url,
187 std::sync::Arc<crate::file_index::FileIndex>,
188 )],
189) -> Option<Location> {
190 let bare = strip_variable_sigil(name);
191 for (uri, idx) in indexes {
192 for f in &idx.functions {
194 if f.name.as_ref() == bare || f.name.as_ref() == name {
195 return Some(zero_width_location(uri, f.start_line));
196 }
197 }
198 for cls in &idx.classes {
200 if cls.name.as_ref() == bare || cls.name.as_ref() == name {
201 return Some(zero_width_location(uri, cls.start_line));
202 }
203 for m in &cls.methods {
205 if m.name.as_ref() == name {
206 return Some(zero_width_location(uri, m.start_line));
207 }
208 }
209 for p in &cls.properties {
211 if p.name.as_ref() == bare {
212 return Some(zero_width_location(uri, p.start_line));
213 }
214 }
215 for cc in &cls.constants {
217 if cc.as_ref() == name {
218 let pos = tower_lsp::lsp_types::Position {
219 line: cls.start_line,
220 character: 0,
221 };
222 return Some(Location {
223 uri: uri.clone(),
224 range: Range {
225 start: pos,
226 end: pos,
227 },
228 });
229 }
230 }
231 for case in &cls.cases {
233 if case.as_ref() == name {
234 let pos = tower_lsp::lsp_types::Position {
235 line: cls.start_line,
236 character: 0,
237 };
238 return Some(Location {
239 uri: uri.clone(),
240 range: Range {
241 start: pos,
242 end: pos,
243 },
244 });
245 }
246 }
247 }
248 }
249 None
250}
251
252pub fn find_method_in_class_hierarchy(
258 class_name: &str,
259 method_name: &str,
260 indexes: &[(
261 tower_lsp::lsp_types::Url,
262 std::sync::Arc<crate::file_index::FileIndex>,
263 )],
264) -> Option<Location> {
265 let mut queue: Vec<String> = vec![class_name.to_owned()];
266 let mut visited = std::collections::HashSet::new();
267
268 while !queue.is_empty() {
269 let current = queue.remove(0);
270 if !visited.insert(current.clone()) {
271 continue;
272 }
273 for (uri, idx) in indexes {
274 for cls in &idx.classes {
275 if cls.name.as_ref() != current.as_str()
276 && cls.fqn.as_ref().trim_start_matches('\\') != current.as_str()
277 {
278 continue;
279 }
280 for m in &cls.methods {
281 if m.name.as_ref() == method_name {
282 let pos = tower_lsp::lsp_types::Position {
283 line: m.start_line,
284 character: 0,
285 };
286 return Some(Location {
287 uri: uri.clone(),
288 range: Range {
289 start: pos,
290 end: pos,
291 },
292 });
293 }
294 }
295 for trt in &cls.traits {
297 queue.push(trt.as_ref().to_owned());
298 }
299 if let Some(parent) = &cls.parent {
300 queue.push(parent.as_ref().to_owned());
301 }
302 }
303 }
304 }
305 None
306}
307
308fn _name_range_from_offset(sv: SourceView<'_>, name: &str) -> Range {
309 let start_offset = str_offset(sv.source(), name).unwrap_or(0);
310 let start = sv.position_of(start_offset);
311 Range {
312 start,
313 end: Position {
314 line: start.line,
315 character: start.character + utf16_code_units(name),
316 },
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use crate::test_utils::cursor;
324
325 fn uri() -> Url {
326 Url::parse("file:///test.php").unwrap()
327 }
328
329 fn pos(line: u32, character: u32) -> Position {
330 Position { line, character }
331 }
332
333 #[test]
334 fn jumps_to_function_definition() {
335 let (src, p) = cursor("<?php\nfunction g$0reet() {}");
336 let doc = ParsedDoc::parse(src.clone());
337 let result = goto_definition(&uri(), &src, &doc, &[], p);
338 assert!(result.is_some(), "expected a location");
339 let loc = result.unwrap();
340 assert_eq!(loc.range.start.line, 1);
341 assert_eq!(loc.uri, uri());
342 }
343
344 #[test]
345 fn jumps_to_class_definition() {
346 let (src, p) = cursor("<?php\nclass My$0Service {}");
347 let doc = ParsedDoc::parse(src.clone());
348 let result = goto_definition(&uri(), &src, &doc, &[], p);
349 assert!(result.is_some());
350 let loc = result.unwrap();
351 assert_eq!(loc.range.start.line, 1);
352 }
353
354 #[test]
355 fn jumps_to_interface_definition() {
356 let (src, p) = cursor("<?php\ninterface Co$0untable {}");
357 let doc = ParsedDoc::parse(src.clone());
358 let result = goto_definition(&uri(), &src, &doc, &[], p);
359 assert!(result.is_some());
360 assert_eq!(result.unwrap().range.start.line, 1);
361 }
362
363 #[test]
364 fn jumps_to_trait_definition() {
365 let src = "<?php\ntrait Loggable {}";
366 let doc = ParsedDoc::parse(src.to_string());
367 let result = goto_definition(&uri(), src, &doc, &[], pos(1, 8));
368 assert!(result.is_some());
369 assert_eq!(result.unwrap().range.start.line, 1);
370 }
371
372 #[test]
373 fn jumps_to_class_method_definition() {
374 let src = "<?php\nclass Calc { public function add() {} }";
375 let doc = ParsedDoc::parse(src.to_string());
376 let result = goto_definition(&uri(), src, &doc, &[], pos(1, 32));
377 assert!(result.is_some(), "expected location for method 'add'");
378 }
379
380 #[test]
381 fn returns_none_for_unknown_word() {
382 let src = "<?php\necho 'hello';";
383 let doc = ParsedDoc::parse(src.to_string());
384 let result = goto_definition(&uri(), src, &doc, &[], pos(1, 6));
386 assert!(result.is_none());
387 }
388
389 #[test]
390 fn variable_goto_definition_jumps_to_first_occurrence() {
391 let src = "<?php\nfunction foo() {\n $x = 1;\n return $x;\n}";
392 let doc = ParsedDoc::parse(src.to_string());
393 let result = goto_definition(&uri(), src, &doc, &[], pos(3, 12));
395 assert!(result.is_some(), "expected location for $x");
396 let loc = result.unwrap();
397 assert_eq!(
399 loc.range.start.line, 2,
400 "should jump to first $x occurrence"
401 );
402 }
403
404 #[test]
405 fn jumps_to_enum_definition() {
406 let src = "<?php\nenum Suit { case Hearts; }";
407 let doc = ParsedDoc::parse(src.to_string());
408 let result = goto_definition(&uri(), src, &doc, &[], pos(1, 7));
409 assert!(result.is_some(), "expected location for enum 'Suit'");
410 assert_eq!(result.unwrap().range.start.line, 1);
411 }
412
413 #[test]
414 fn jumps_to_enum_case_definition() {
415 let src = "<?php\nenum Suit { case Hearts; case Spades; }";
416 let doc = ParsedDoc::parse(src.to_string());
417 let result = goto_definition(&uri(), src, &doc, &[], pos(1, 22));
418 assert!(result.is_some(), "expected location for enum case 'Hearts'");
419 }
420
421 #[test]
422 fn jumps_to_enum_method_definition() {
423 let src = "<?php\nenum Suit { public function label(): string { return ''; } }";
424 let doc = ParsedDoc::parse(src.to_string());
425 let result = goto_definition(&uri(), src, &doc, &[], pos(1, 30));
426 assert!(
427 result.is_some(),
428 "expected location for enum method 'label'"
429 );
430 }
431
432 #[test]
433 fn jumps_to_symbol_inside_namespace() {
434 let src = "<?php\nnamespace App {\nfunction boot() {}\n}";
435 let doc = ParsedDoc::parse(src.to_string());
436 let result = goto_definition(&uri(), src, &doc, &[], pos(2, 10));
437 assert!(result.is_some());
438 assert_eq!(result.unwrap().range.start.line, 2);
439 }
440
441 #[test]
442 fn finds_class_definition_in_other_document() {
443 let current_src = "<?php\n$s = new MyService();";
444 let current_doc = ParsedDoc::parse(current_src.to_string());
445 let other_src = "<?php\nclass MyService {}";
446 let other_uri = Url::parse("file:///other.php").unwrap();
447 let other_doc = Arc::new(ParsedDoc::parse(other_src.to_string()));
448
449 let result = goto_definition(
450 &uri(),
451 current_src,
452 ¤t_doc,
453 &[(other_uri.clone(), other_doc)],
454 pos(1, 13),
455 );
456 assert!(result.is_some(), "expected cross-file location");
457 assert_eq!(result.unwrap().uri, other_uri);
458 }
459
460 #[test]
461 fn finds_function_definition_in_other_document() {
462 let current_src = "<?php\nhelperFn();";
463 let current_doc = ParsedDoc::parse(current_src.to_string());
464 let other_src = "<?php\nfunction helperFn() {}";
465 let other_uri = Url::parse("file:///helpers.php").unwrap();
466 let other_doc = Arc::new(ParsedDoc::parse(other_src.to_string()));
467
468 let result = goto_definition(
469 &uri(),
470 current_src,
471 ¤t_doc,
472 &[(other_uri.clone(), other_doc)],
473 pos(1, 3),
474 );
475 assert!(
476 result.is_some(),
477 "expected cross-file location for helperFn"
478 );
479 assert_eq!(result.unwrap().uri, other_uri);
480 }
481
482 #[test]
483 fn current_file_takes_priority_over_other_docs() {
484 let src = "<?php\nclass Foo {}";
485 let doc = ParsedDoc::parse(src.to_string());
486 let other_src = "<?php\nclass Foo {}";
487 let other_uri = Url::parse("file:///other.php").unwrap();
488 let other_doc = Arc::new(ParsedDoc::parse(other_src.to_string()));
489
490 let result = goto_definition(&uri(), src, &doc, &[(other_uri, other_doc)], pos(1, 8));
491 assert_eq!(result.unwrap().uri, uri(), "should prefer current file");
492 }
493
494 #[test]
495 fn goto_definition_class_constant() {
496 let src = "<?php\nclass MyClass { const STATUS_OK = 1; }";
500 let doc = ParsedDoc::parse(src.to_string());
501 let result = goto_definition(&uri(), src, &doc, &[], pos(1, 22));
503 assert!(
504 result.is_some(),
505 "expected a location for class constant STATUS_OK"
506 );
507 let loc = result.unwrap();
508 assert_eq!(
509 loc.range.start.line, 1,
510 "should jump to line 1 where the constant is declared"
511 );
512 assert_eq!(loc.uri, uri(), "should be in the same file");
513 }
514
515 #[test]
516 fn goto_definition_property() {
517 let src = "<?php\nclass Person { public string $name; }";
523 let doc = ParsedDoc::parse(src.to_string());
524 let result = goto_definition(&uri(), src, &doc, &[], pos(1, 30));
526 assert!(
527 result.is_some(),
528 "expected a location for property '$name', cursor at column 30"
529 );
530 let loc = result.unwrap();
531 assert_eq!(
532 loc.range.start.line, 1,
533 "should jump to line 1 where the property is declared"
534 );
535 assert_eq!(loc.uri, uri(), "should be in the same file");
536 }
537
538 #[test]
539 fn jumps_to_trait_method_definition() {
540 let src = "<?php\ntrait Greeting {\n public function sayHello(string $name): string { return ''; }\n}";
541 let doc = ParsedDoc::parse(src.to_string());
542 let result = goto_definition(&uri(), src, &doc, &[], pos(2, 22));
543 assert!(
544 result.is_some(),
545 "expected location for trait method 'sayHello'"
546 );
547 assert_eq!(result.unwrap().range.start.line, 2);
548 }
549
550 fn make_index(path: &str, src: &str) -> (Url, std::sync::Arc<crate::file_index::FileIndex>) {
553 use crate::file_index::FileIndex;
554 let u = Url::parse(&format!("file://{path}")).unwrap();
555 let d = ParsedDoc::parse(src.to_string());
556 (u, std::sync::Arc::new(FileIndex::extract(&d)))
557 }
558
559 #[test]
560 fn hierarchy_finds_method_in_class_itself() {
561 let (uri, idx) = make_index(
562 "/a.php",
563 "<?php\nclass Foo { public function bar(): void {} }",
564 );
565 let indexes = vec![(uri, idx)];
566 let loc = find_method_in_class_hierarchy("Foo", "bar", &indexes);
567 assert!(loc.is_some(), "expected bar() in Foo");
568 assert_eq!(loc.unwrap().range.start.line, 1);
569 }
570
571 #[test]
572 fn hierarchy_finds_method_in_parent() {
573 let (base_uri, base_idx) = make_index(
574 "/Base.php",
575 "<?php\nclass Base { public function render(): void {} }",
576 );
577 let (cu, ci) = make_index("/Child.php", "<?php\nclass Child extends Base {}");
578 let indexes = vec![(base_uri.clone(), base_idx), (cu, ci)];
579 let loc = find_method_in_class_hierarchy("Child", "render", &indexes);
580 assert!(loc.is_some(), "expected render() found via parent Base");
581 assert_eq!(loc.unwrap().uri, base_uri);
582 }
583
584 #[test]
585 fn hierarchy_finds_method_in_trait() {
586 let (trait_uri, trait_idx) = make_index(
587 "/Renderable.php",
588 "<?php\ntrait Renderable { public function render(): void {} }",
589 );
590 let (pu, pi) = make_index("/Page.php", "<?php\nclass Page { use Renderable; }");
591 let indexes = vec![(trait_uri.clone(), trait_idx), (pu, pi)];
592 let loc = find_method_in_class_hierarchy("Page", "render", &indexes);
593 assert!(loc.is_some(), "expected render() found via trait");
594 assert_eq!(loc.unwrap().uri, trait_uri);
595 }
596
597 #[test]
598 fn hierarchy_returns_none_for_missing_method() {
599 let (uri, idx) = make_index("/Foo.php", "<?php\nclass Foo {}");
600 let indexes = vec![(uri, idx)];
601 assert!(find_method_in_class_hierarchy("Foo", "missing", &indexes).is_none());
602 }
603
604 #[test]
605 fn hierarchy_handles_cycle_without_panic() {
606 let (ua, ia) = make_index("/A.php", "<?php\nclass A extends B {}");
608 let (ub, ib) = make_index("/B.php", "<?php\nclass B extends A {}");
609 let indexes = vec![(ua, ia), (ub, ib)];
610 let loc = find_method_in_class_hierarchy("A", "missing", &indexes);
611 assert!(loc.is_none());
612 }
613}