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::word_at;
8use crate::walk::collect_var_refs_in_scope;
9
10/// Find the definition of the symbol under `position`.
11/// Searches the current document first, then `other_docs` for cross-file resolution.
12pub 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    // For $variable, find the first occurrence in scope (= the definition/assignment).
22    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
59/// Search an AST for a declaration named `name`, returning its selection range.
60/// Used by the PSR-4 fallback in the backend after resolving a class to a file.
61pub 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    // Strip a leading `$` so that `$name` matches property names stored without `$`.
68    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                        // Constructor-promoted parameters act as property declarations.
91                        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
166/// Find a class/function declaration by name in a slice of `FileIndex` entries.
167/// Returns the URI and a line-level `Range`.
168pub 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        // Check top-level functions.
178        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        // Check classes / interfaces / traits / enums and their members.
194        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            // Methods.
209            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            // Properties (stored without `$`).
225            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            // Class constants.
241            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            // Enum cases.
257            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        // `hello` is a string literal, not a symbol — no definition found.
354        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        // Cursor on `$x` in `return $x;` (line 3)
363        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        // First occurrence is on line 2 (the assignment)
367        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            &current_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            &current_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        // Cursor on `STATUS_OK` in the class constant declaration should jump to `const STATUS_OK`.
466        // Source: line 0 = <?php, line 1 = class MyClass { const STATUS_OK = 1; }
467        // The cursor is placed on `STATUS_OK` inside the const declaration.
468        let src = "<?php\nclass MyClass { const STATUS_OK = 1; }";
469        let doc = ParsedDoc::parse(src.to_string());
470        // `STATUS_OK` starts at column 22 on line 1 (after "class MyClass { const ")
471        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        // Cursor on the property `$name` in its declaration should jump to that declaration.
487        // Source: line 0 = <?php, line 1 = class Person { public string $name; }
488        // Column breakdown of line 1: "class Person { public string $name; }"
489        //   col 0-4: "class", 5: " ", 6-11: "Person", 12: " ", 13: "{", 14: " ",
490        //   15-20: "public", 21: " ", 22-27: "string", 28: " ", 29: "$", 30-33: "name"
491        let src = "<?php\nclass Person { public string $name; }";
492        let doc = ParsedDoc::parse(src.to_string());
493        // Cursor on column 30 — on the `n` in `$name`.
494        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}