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