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, name_range, offset_to_position, str_offset};
7use crate::util::{utf16_pos_to_byte, 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    if word.starts_with('$') {
23        let bare = word.trim_start_matches('$');
24        let byte_off = utf16_pos_to_byte(source, position);
25        let mut spans = Vec::new();
26        collect_var_refs_in_scope(&doc.program().stmts, bare, byte_off, &mut spans);
27        if let Some(span) = spans.into_iter().min_by_key(|s| s.start) {
28            return Some(Location {
29                uri: uri.clone(),
30                range: Range {
31                    start: offset_to_position(source, span.start),
32                    end: offset_to_position(source, span.end),
33                },
34            });
35        }
36    }
37
38    if let Some(range) = scan_statements(source, &doc.program().stmts, &word) {
39        return Some(Location {
40            uri: uri.clone(),
41            range,
42        });
43    }
44
45    for (other_uri, other_doc) in other_docs {
46        let other_source = other_doc.source();
47        if let Some(range) = scan_statements(other_source, &other_doc.program().stmts, &word) {
48            return Some(Location {
49                uri: other_uri.clone(),
50                range,
51            });
52        }
53    }
54
55    None
56}
57
58/// Search an AST for a declaration named `name`, returning its selection range.
59/// Used by the PSR-4 fallback in the backend after resolving a class to a file.
60pub fn find_declaration_range(source: &str, doc: &ParsedDoc, name: &str) -> Option<Range> {
61    scan_statements(source, &doc.program().stmts, name)
62}
63
64fn scan_statements(source: &str, stmts: &[Stmt<'_, '_>], word: &str) -> Option<Range> {
65    // Strip a leading `$` so that `$name` matches property names stored without `$`.
66    let bare = word.strip_prefix('$').unwrap_or(word);
67    for stmt in stmts {
68        match &stmt.kind {
69            StmtKind::Function(f) if f.name == word => {
70                return Some(name_range(source, f.name));
71            }
72            StmtKind::Class(c) if c.name == Some(word) => {
73                let name = c.name.expect("match guard ensures Some");
74                return Some(name_range(source, name));
75            }
76            StmtKind::Class(c) => {
77                for member in c.members.iter() {
78                    match &member.kind {
79                        ClassMemberKind::Method(m) if m.name == word => {
80                            return Some(name_range(source, m.name));
81                        }
82                        ClassMemberKind::ClassConst(cc) if cc.name == word => {
83                            return Some(name_range(source, cc.name));
84                        }
85                        ClassMemberKind::Property(p) if p.name == bare => {
86                            return Some(name_range(source, p.name));
87                        }
88                        _ => {}
89                    }
90                }
91            }
92            StmtKind::Interface(i) if i.name == word => {
93                return Some(name_range(source, i.name));
94            }
95            StmtKind::Trait(t) if t.name == word => {
96                return Some(name_range(source, t.name));
97            }
98            StmtKind::Enum(e) if e.name == word => {
99                return Some(name_range(source, e.name));
100            }
101            StmtKind::Enum(e) => {
102                for member in e.members.iter() {
103                    match &member.kind {
104                        EnumMemberKind::Method(m) if m.name == word => {
105                            return Some(name_range(source, m.name));
106                        }
107                        EnumMemberKind::Case(c) if c.name == word => {
108                            return Some(name_range(source, c.name));
109                        }
110                        _ => {}
111                    }
112                }
113            }
114            StmtKind::Namespace(ns) => {
115                if let NamespaceBody::Braced(inner) = &ns.body
116                    && let Some(range) = scan_statements(source, inner, word)
117                {
118                    return Some(range);
119                }
120            }
121            _ => {}
122        }
123    }
124    None
125}
126
127fn _name_range_from_offset(source: &str, name: &str) -> Range {
128    let start_offset = str_offset(source, name);
129    let start = offset_to_position(source, start_offset);
130    Range {
131        start,
132        end: Position {
133            line: start.line,
134            character: start.character + name.chars().map(|c| c.len_utf16() as u32).sum::<u32>(),
135        },
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::test_utils::cursor;
143
144    fn uri() -> Url {
145        Url::parse("file:///test.php").unwrap()
146    }
147
148    fn pos(line: u32, character: u32) -> Position {
149        Position { line, character }
150    }
151
152    #[test]
153    fn jumps_to_function_definition() {
154        let (src, p) = cursor("<?php\nfunction g$0reet() {}");
155        let doc = ParsedDoc::parse(src.clone());
156        let result = goto_definition(&uri(), &src, &doc, &[], p);
157        assert!(result.is_some(), "expected a location");
158        let loc = result.unwrap();
159        assert_eq!(loc.range.start.line, 1);
160        assert_eq!(loc.uri, uri());
161    }
162
163    #[test]
164    fn jumps_to_class_definition() {
165        let (src, p) = cursor("<?php\nclass My$0Service {}");
166        let doc = ParsedDoc::parse(src.clone());
167        let result = goto_definition(&uri(), &src, &doc, &[], p);
168        assert!(result.is_some());
169        let loc = result.unwrap();
170        assert_eq!(loc.range.start.line, 1);
171    }
172
173    #[test]
174    fn jumps_to_interface_definition() {
175        let (src, p) = cursor("<?php\ninterface Co$0untable {}");
176        let doc = ParsedDoc::parse(src.clone());
177        let result = goto_definition(&uri(), &src, &doc, &[], p);
178        assert!(result.is_some());
179        assert_eq!(result.unwrap().range.start.line, 1);
180    }
181
182    #[test]
183    fn jumps_to_trait_definition() {
184        let src = "<?php\ntrait Loggable {}";
185        let doc = ParsedDoc::parse(src.to_string());
186        let result = goto_definition(&uri(), src, &doc, &[], pos(1, 8));
187        assert!(result.is_some());
188        assert_eq!(result.unwrap().range.start.line, 1);
189    }
190
191    #[test]
192    fn jumps_to_class_method_definition() {
193        let src = "<?php\nclass Calc { public function add() {} }";
194        let doc = ParsedDoc::parse(src.to_string());
195        let result = goto_definition(&uri(), src, &doc, &[], pos(1, 32));
196        assert!(result.is_some(), "expected location for method 'add'");
197    }
198
199    #[test]
200    fn returns_none_for_unknown_word() {
201        let src = "<?php\necho 'hello';";
202        let doc = ParsedDoc::parse(src.to_string());
203        // `hello` is a string literal, not a symbol — no definition found.
204        let result = goto_definition(&uri(), src, &doc, &[], pos(1, 6));
205        assert!(result.is_none());
206    }
207
208    #[test]
209    fn variable_goto_definition_jumps_to_first_occurrence() {
210        let src = "<?php\nfunction foo() {\n    $x = 1;\n    return $x;\n}";
211        let doc = ParsedDoc::parse(src.to_string());
212        // Cursor on `$x` in `return $x;` (line 3)
213        let result = goto_definition(&uri(), src, &doc, &[], pos(3, 12));
214        assert!(result.is_some(), "expected location for $x");
215        let loc = result.unwrap();
216        // First occurrence is on line 2 (the assignment)
217        assert_eq!(
218            loc.range.start.line, 2,
219            "should jump to first $x occurrence"
220        );
221    }
222
223    #[test]
224    fn jumps_to_enum_definition() {
225        let src = "<?php\nenum Suit { case Hearts; }";
226        let doc = ParsedDoc::parse(src.to_string());
227        let result = goto_definition(&uri(), src, &doc, &[], pos(1, 7));
228        assert!(result.is_some(), "expected location for enum 'Suit'");
229        assert_eq!(result.unwrap().range.start.line, 1);
230    }
231
232    #[test]
233    fn jumps_to_enum_case_definition() {
234        let src = "<?php\nenum Suit { case Hearts; case Spades; }";
235        let doc = ParsedDoc::parse(src.to_string());
236        let result = goto_definition(&uri(), src, &doc, &[], pos(1, 22));
237        assert!(result.is_some(), "expected location for enum case 'Hearts'");
238    }
239
240    #[test]
241    fn jumps_to_enum_method_definition() {
242        let src = "<?php\nenum Suit { public function label(): string { return ''; } }";
243        let doc = ParsedDoc::parse(src.to_string());
244        let result = goto_definition(&uri(), src, &doc, &[], pos(1, 30));
245        assert!(
246            result.is_some(),
247            "expected location for enum method 'label'"
248        );
249    }
250
251    #[test]
252    fn jumps_to_symbol_inside_namespace() {
253        let src = "<?php\nnamespace App {\nfunction boot() {}\n}";
254        let doc = ParsedDoc::parse(src.to_string());
255        let result = goto_definition(&uri(), src, &doc, &[], pos(2, 10));
256        assert!(result.is_some());
257        assert_eq!(result.unwrap().range.start.line, 2);
258    }
259
260    #[test]
261    fn finds_class_definition_in_other_document() {
262        let current_src = "<?php\n$s = new MyService();";
263        let current_doc = ParsedDoc::parse(current_src.to_string());
264        let other_src = "<?php\nclass MyService {}";
265        let other_uri = Url::parse("file:///other.php").unwrap();
266        let other_doc = Arc::new(ParsedDoc::parse(other_src.to_string()));
267
268        let result = goto_definition(
269            &uri(),
270            current_src,
271            &current_doc,
272            &[(other_uri.clone(), other_doc)],
273            pos(1, 13),
274        );
275        assert!(result.is_some(), "expected cross-file location");
276        assert_eq!(result.unwrap().uri, other_uri);
277    }
278
279    #[test]
280    fn finds_function_definition_in_other_document() {
281        let current_src = "<?php\nhelperFn();";
282        let current_doc = ParsedDoc::parse(current_src.to_string());
283        let other_src = "<?php\nfunction helperFn() {}";
284        let other_uri = Url::parse("file:///helpers.php").unwrap();
285        let other_doc = Arc::new(ParsedDoc::parse(other_src.to_string()));
286
287        let result = goto_definition(
288            &uri(),
289            current_src,
290            &current_doc,
291            &[(other_uri.clone(), other_doc)],
292            pos(1, 3),
293        );
294        assert!(
295            result.is_some(),
296            "expected cross-file location for helperFn"
297        );
298        assert_eq!(result.unwrap().uri, other_uri);
299    }
300
301    #[test]
302    fn current_file_takes_priority_over_other_docs() {
303        let src = "<?php\nclass Foo {}";
304        let doc = ParsedDoc::parse(src.to_string());
305        let other_src = "<?php\nclass Foo {}";
306        let other_uri = Url::parse("file:///other.php").unwrap();
307        let other_doc = Arc::new(ParsedDoc::parse(other_src.to_string()));
308
309        let result = goto_definition(&uri(), src, &doc, &[(other_uri, other_doc)], pos(1, 8));
310        assert_eq!(result.unwrap().uri, uri(), "should prefer current file");
311    }
312
313    #[test]
314    fn goto_definition_class_constant() {
315        // Cursor on `STATUS_OK` in the class constant declaration should jump to `const STATUS_OK`.
316        // Source: line 0 = <?php, line 1 = class MyClass { const STATUS_OK = 1; }
317        // The cursor is placed on `STATUS_OK` inside the const declaration.
318        let src = "<?php\nclass MyClass { const STATUS_OK = 1; }";
319        let doc = ParsedDoc::parse(src.to_string());
320        // `STATUS_OK` starts at column 22 on line 1 (after "class MyClass { const ")
321        let result = goto_definition(&uri(), src, &doc, &[], pos(1, 22));
322        assert!(
323            result.is_some(),
324            "expected a location for class constant STATUS_OK"
325        );
326        let loc = result.unwrap();
327        assert_eq!(
328            loc.range.start.line, 1,
329            "should jump to line 1 where the constant is declared"
330        );
331        assert_eq!(loc.uri, uri(), "should be in the same file");
332    }
333
334    #[test]
335    fn goto_definition_property() {
336        // Cursor on the property `$name` in its declaration should jump to that declaration.
337        // Source: line 0 = <?php, line 1 = class Person { public string $name; }
338        // Column breakdown of line 1: "class Person { public string $name; }"
339        //   col 0-4: "class", 5: " ", 6-11: "Person", 12: " ", 13: "{", 14: " ",
340        //   15-20: "public", 21: " ", 22-27: "string", 28: " ", 29: "$", 30-33: "name"
341        let src = "<?php\nclass Person { public string $name; }";
342        let doc = ParsedDoc::parse(src.to_string());
343        // Cursor on column 30 — on the `n` in `$name`.
344        let result = goto_definition(&uri(), src, &doc, &[], pos(1, 30));
345        assert!(
346            result.is_some(),
347            "expected a location for property '$name', cursor at column 30"
348        );
349        let loc = result.unwrap();
350        assert_eq!(
351            loc.range.start.line, 1,
352            "should jump to line 1 where the property is declared"
353        );
354        assert_eq!(loc.uri, uri(), "should be in the same file");
355    }
356}