Skip to main content

php_lsp/
definition.rs

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
21/// Find the definition of the symbol under `position`.
22/// Searches the current document first, then `other_docs` for cross-file resolution.
23pub 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    // For $variable, find the first occurrence in scope (= the definition/assignment).
33    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
70/// Search an AST for a declaration named `name`, returning its selection range.
71/// Used by the PSR-4 fallback in the backend after resolving a class to a file.
72pub 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    // Strip a leading `$` so that `$name` matches property names stored without `$`.
79    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                        // Constructor-promoted parameters act as property declarations.
104                        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
181/// Find a class/function declaration by name in a slice of `FileIndex` entries.
182/// Returns the URI and a line-level `Range`.
183pub 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        // Check top-level functions.
193        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        // Check classes / interfaces / traits / enums and their members.
199        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            // Methods.
204            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            // Properties (stored without `$`).
210            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            // Class constants.
216            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            // Enum cases.
232            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
252/// Walk the class hierarchy (extends + traits) in the workspace index to find
253/// `method_name` defined in `class_name` or any of its superclasses/traits.
254///
255/// Returns the first match in PHP's resolution order: class itself → traits →
256/// parent → parent's traits, etc. Uses `indexes` so no disk I/O is needed.
257pub 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                // Traits first (PHP MRO), then parent.
296                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        // `hello` is a string literal, not a symbol — no definition found.
385        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        // Cursor on `$x` in `return $x;` (line 3)
394        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        // First occurrence is on line 2 (the assignment)
398        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            &current_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            &current_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        // Cursor on `STATUS_OK` in the class constant declaration should jump to `const STATUS_OK`.
497        // Source: line 0 = <?php, line 1 = class MyClass { const STATUS_OK = 1; }
498        // The cursor is placed on `STATUS_OK` inside the const declaration.
499        let src = "<?php\nclass MyClass { const STATUS_OK = 1; }";
500        let doc = ParsedDoc::parse(src.to_string());
501        // `STATUS_OK` starts at column 22 on line 1 (after "class MyClass { const ")
502        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        // Cursor on the property `$name` in its declaration should jump to that declaration.
518        // Source: line 0 = <?php, line 1 = class Person { public string $name; }
519        // Column breakdown of line 1: "class Person { public string $name; }"
520        //   col 0-4: "class", 5: " ", 6-11: "Person", 12: " ", 13: "{", 14: " ",
521        //   15-20: "public", 21: " ", 22-27: "string", 28: " ", 29: "$", 30-33: "name"
522        let src = "<?php\nclass Person { public string $name; }";
523        let doc = ParsedDoc::parse(src.to_string());
524        // Cursor on column 30 — on the `n` in `$name`.
525        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    // ── find_method_in_class_hierarchy ───────────────────────────────────────
551
552    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        // Bogus source where A extends B extends A — must not loop forever.
607        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}