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.body.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.body.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.body.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.body.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.stmts, 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
324    // ── find_method_in_class_hierarchy ───────────────────────────────────────
325
326    fn make_index(path: &str, src: &str) -> (Url, std::sync::Arc<crate::file_index::FileIndex>) {
327        use crate::file_index::FileIndex;
328        let u = Url::parse(&format!("file://{path}")).unwrap();
329        let d = ParsedDoc::parse(src.to_string());
330        (u, std::sync::Arc::new(FileIndex::extract(&d)))
331    }
332
333    #[test]
334    fn hierarchy_finds_method_in_class_itself() {
335        let (uri, idx) = make_index(
336            "/a.php",
337            "<?php\nclass Foo { public function bar(): void {} }",
338        );
339        let indexes = vec![(uri, idx)];
340        let loc = find_method_in_class_hierarchy("Foo", "bar", &indexes);
341        assert!(loc.is_some(), "expected bar() in Foo");
342        assert_eq!(loc.unwrap().range.start.line, 1);
343    }
344
345    #[test]
346    fn hierarchy_finds_method_in_parent() {
347        let (base_uri, base_idx) = make_index(
348            "/Base.php",
349            "<?php\nclass Base { public function render(): void {} }",
350        );
351        let (cu, ci) = make_index("/Child.php", "<?php\nclass Child extends Base {}");
352        let indexes = vec![(base_uri.clone(), base_idx), (cu, ci)];
353        let loc = find_method_in_class_hierarchy("Child", "render", &indexes);
354        assert!(loc.is_some(), "expected render() found via parent Base");
355        assert_eq!(loc.unwrap().uri, base_uri);
356    }
357
358    #[test]
359    fn hierarchy_finds_method_in_trait() {
360        let (trait_uri, trait_idx) = make_index(
361            "/Renderable.php",
362            "<?php\ntrait Renderable { public function render(): void {} }",
363        );
364        let (pu, pi) = make_index("/Page.php", "<?php\nclass Page { use Renderable; }");
365        let indexes = vec![(trait_uri.clone(), trait_idx), (pu, pi)];
366        let loc = find_method_in_class_hierarchy("Page", "render", &indexes);
367        assert!(loc.is_some(), "expected render() found via trait");
368        assert_eq!(loc.unwrap().uri, trait_uri);
369    }
370
371    #[test]
372    fn hierarchy_returns_none_for_missing_method() {
373        let (uri, idx) = make_index("/Foo.php", "<?php\nclass Foo {}");
374        let indexes = vec![(uri, idx)];
375        assert!(find_method_in_class_hierarchy("Foo", "missing", &indexes).is_none());
376    }
377
378    #[test]
379    fn hierarchy_handles_cycle_without_panic() {
380        // Bogus source where A extends B extends A — must not loop forever.
381        let (ua, ia) = make_index("/A.php", "<?php\nclass A extends B {}");
382        let (ub, ib) = make_index("/B.php", "<?php\nclass B extends A {}");
383        let indexes = vec![(ua, ia), (ub, ib)];
384        let loc = find_method_in_class_hierarchy("A", "missing", &indexes);
385        assert!(loc.is_none());
386    }
387}