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                        _ => {}
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
145/// Find a class/function declaration by name in a slice of `FileIndex` entries.
146/// Returns the URI and a line-level `Range`.
147pub 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        // Check top-level functions.
157        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        // Check classes / interfaces / traits / enums and their members.
173        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            // Methods.
188            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            // Properties (stored without `$`).
204            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            // Class constants.
220            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            // Enum cases.
236            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        // `hello` is a string literal, not a symbol — no definition found.
333        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        // Cursor on `$x` in `return $x;` (line 3)
342        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        // First occurrence is on line 2 (the assignment)
346        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            &current_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            &current_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        // Cursor on `STATUS_OK` in the class constant declaration should jump to `const STATUS_OK`.
445        // Source: line 0 = <?php, line 1 = class MyClass { const STATUS_OK = 1; }
446        // The cursor is placed on `STATUS_OK` inside the const declaration.
447        let src = "<?php\nclass MyClass { const STATUS_OK = 1; }";
448        let doc = ParsedDoc::parse(src.to_string());
449        // `STATUS_OK` starts at column 22 on line 1 (after "class MyClass { const ")
450        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        // Cursor on the property `$name` in its declaration should jump to that declaration.
466        // Source: line 0 = <?php, line 1 = class Person { public string $name; }
467        // Column breakdown of line 1: "class Person { public string $name; }"
468        //   col 0-4: "class", 5: " ", 6-11: "Person", 12: " ", 13: "{", 14: " ",
469        //   15-20: "public", 21: " ", 22-27: "string", 28: " ", 29: "$", 30-33: "name"
470        let src = "<?php\nclass Person { public string $name; }";
471        let doc = ParsedDoc::parse(src.to_string());
472        // Cursor on column 30 — on the `n` in `$name`.
473        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}