Skip to main content

php_lsp/navigation/
declaration.rs

1/// `textDocument/declaration` — jump to the abstract or interface declaration of a symbol.
2///
3/// In PHP the distinction between declaration and definition matters for:
4///   - Interface methods (declared but never given a body)
5///   - Abstract class methods
6///
7/// For concrete symbols with no abstract counterpart this falls back to the same
8/// result as go-to-definition so the request is never empty-handed.
9use std::sync::Arc;
10
11use tower_lsp_server::ls_types::{Location, Position, Uri};
12
13use crate::document::ast::ParsedDoc;
14use crate::lang::is_bare_keyword_at;
15use crate::text::{strip_variable_sigil, utf16_code_units, word_at_position};
16use crate::types::resolve::{Container, Declaration, resolve_declaration};
17
18/// Find the abstract or interface declaration of `word`.
19/// Prefers abstract/interface declarations; falls back to any declaration.
20pub fn goto_declaration(
21    source: &str,
22    all_docs: &[(Uri, Arc<ParsedDoc>)],
23    position: Position,
24) -> Option<Location> {
25    let word = word_at_position(source, position)?;
26
27    // First pass: look for an abstract or interface declaration
28    for (uri, doc) in all_docs {
29        let sv = doc.view();
30        if let Some(decl) =
31            resolve_declaration(&doc.program().stmts, &word, &is_abstract_declaration)
32        {
33            return Some(Location {
34                uri: uri.clone(),
35                range: sv.name_range_in_span(decl.name(), decl.span()),
36            });
37        }
38    }
39
40    // Second pass: any declaration (same as goto_definition)
41    for (uri, doc) in all_docs {
42        let sv = doc.view();
43        if let Some(decl) = resolve_declaration(&doc.program().stmts, &word, &is_any_declaration) {
44            return Some(Location {
45                uri: uri.clone(),
46                range: sv.name_range_in_span(decl.name(), decl.span()),
47            });
48        }
49    }
50
51    None
52}
53
54/// Pass 1: abstract/interface declarations only — interface members and names,
55/// plus abstract methods on classes and traits.
56///
57/// `resolve_declaration` checks a type's name before its members, whereas the original
58/// walker checked interface members first. This only differs for an interface
59/// named the same as a method it contains — syntactically legal but absurd, and
60/// never seen in real PHP, so the order is harmless here.
61fn is_abstract_declaration(decl: &Declaration<'_>) -> bool {
62    match decl {
63        Declaration::Interface { .. } => true,
64        Declaration::Method {
65            container: Container::Interface,
66            ..
67        } => true,
68        Declaration::Method {
69            method,
70            container: Container::Class | Container::Trait,
71            ..
72        } => method.is_abstract,
73        _ => false,
74    }
75}
76
77/// Pass 2: any declaration. Constructor-promoted parameters are not surfaced as
78/// declarations here (the original `find_any_declaration` never matched them).
79fn is_any_declaration(decl: &Declaration<'_>) -> bool {
80    !matches!(decl, Declaration::PromotedParam { .. })
81}
82
83fn precise_range(line: u32, name_char: u32, name: &str) -> tower_lsp_server::ls_types::Range {
84    let end_char = name_char + utf16_code_units(name);
85    tower_lsp_server::ls_types::Range {
86        start: tower_lsp_server::ls_types::Position {
87            line,
88            character: name_char,
89        },
90        end: tower_lsp_server::ls_types::Position {
91            line,
92            character: end_char,
93        },
94    }
95}
96
97/// Pass-2 body scoped to a single file: any declaration named `word`
98/// (`bare` for properties, which are indexed without their `$` sigil).
99/// Shared by the `decls_by_name` fast path and the exhaustive fallback scan
100/// below, so both paths apply identical matching rules.
101fn any_declaration_in_file(
102    uri: &tower_lsp_server::ls_types::Uri,
103    idx: &crate::index::file_index::FileIndex,
104    word: &str,
105    bare: &str,
106) -> Option<Location> {
107    use crate::index::file_index::ClassKind;
108
109    for f in &idx.functions {
110        if f.name.as_ref() == word {
111            return Some(Location {
112                uri: uri.clone(),
113                range: precise_range(f.start_line, f.name_char, &f.name),
114            });
115        }
116    }
117
118    for cls in &idx.classes {
119        // Class/Interface/Trait/Enum declarations.
120        if cls.name.as_ref() == word {
121            return Some(Location {
122                uri: uri.clone(),
123                range: precise_range(cls.start_line, cls.name_char, &cls.name),
124            });
125        }
126
127        // Methods.
128        for m in &cls.methods {
129            if m.name.as_ref() == word {
130                return Some(Location {
131                    uri: uri.clone(),
132                    range: precise_range(m.start_line, m.name_char, &m.name),
133                });
134            }
135        }
136
137        // `@method` docblock methods — zero-width location at the tag line.
138        for dm in &cls.doc_methods {
139            if dm.name.as_ref() == word {
140                return Some(Location {
141                    uri: uri.clone(),
142                    range: crate::text::zero_width_range(dm.start_line),
143                });
144            }
145        }
146
147        // Properties.
148        for p in &cls.properties {
149            if p.name.as_ref() == bare {
150                return Some(Location {
151                    uri: uri.clone(),
152                    range: precise_range(p.start_line, p.name_char, &p.name),
153                });
154            }
155        }
156
157        // Class/Interface/Trait/Enum constants.
158        for c in &cls.constants {
159            if c.as_ref() == word {
160                return Some(Location {
161                    uri: uri.clone(),
162                    range: precise_range(cls.start_line, cls.name_char, &cls.name),
163                });
164            }
165        }
166
167        // Enum cases (stored in separate `cases` field).
168        if cls.kind == ClassKind::Enum {
169            for case_name in &cls.cases {
170                if case_name.as_ref() == word {
171                    return Some(Location {
172                        uri: uri.clone(),
173                        range: precise_range(cls.start_line, cls.name_char, &cls.name),
174                    });
175                }
176            }
177        }
178    }
179    None
180}
181
182/// Find abstract or interface declaration using the aggregated workspace
183/// index. Returns line-only positions (character 0) for unopened files.
184/// This is a limitation of the compact FileIndex — for opened files,
185/// goto_declaration() provides precise name ranges.
186pub fn goto_declaration_from_index(
187    source: &str,
188    wi: &crate::db::workspace_index::WorkspaceIndexData,
189    position: tower_lsp_server::ls_types::Position,
190) -> Option<Location> {
191    use crate::index::file_index::ClassKind;
192    use crate::text::word_at_position;
193    let word = word_at_position(source, position)?;
194    // A bare keyword (`abstract`, `final`, `class`, ...) can never be a
195    // declaration name — bail out before either full-workspace scan below.
196    // `decls_by_name` would always miss for these anyway, so without this
197    // check every keyword click pays for the exhaustive fallback loop.
198    if is_bare_keyword_at(source, position, &word) {
199        return None;
200    }
201    let bare = strip_variable_sigil(&word);
202
203    // First pass: abstract/interface declarations. Already bounded to a
204    // small subset of classes (interfaces, traits, abstract classes), so
205    // left as a scan rather than adding another index for it.
206    for (uri, idx) in &wi.files {
207        for cls in &idx.classes {
208            match cls.kind {
209                ClassKind::Interface => {
210                    // Interface itself.
211                    if cls.name.as_ref() == word {
212                        return Some(Location {
213                            uri: uri.clone(),
214                            range: precise_range(cls.start_line, cls.name_char, &cls.name),
215                        });
216                    }
217                    // Abstract method in interface.
218                    for m in &cls.methods {
219                        if m.name.as_ref() == word {
220                            return Some(Location {
221                                uri: uri.clone(),
222                                range: precise_range(m.start_line, m.name_char, &m.name),
223                            });
224                        }
225                    }
226                }
227                ClassKind::Trait => {
228                    // Trait abstract methods.
229                    for m in &cls.methods {
230                        if m.is_abstract && m.name.as_ref() == word {
231                            return Some(Location {
232                                uri: uri.clone(),
233                                range: precise_range(m.start_line, m.name_char, &m.name),
234                            });
235                        }
236                    }
237                }
238                _ if cls.is_abstract => {
239                    // Abstract methods in abstract classes.
240                    for m in &cls.methods {
241                        if m.is_abstract && m.name.as_ref() == word {
242                            return Some(Location {
243                                uri: uri.clone(),
244                                range: precise_range(m.start_line, m.name_char, &m.name),
245                            });
246                        }
247                    }
248                }
249                _ => {}
250            }
251        }
252    }
253
254    // Second pass: any declaration. `decls_by_name` gives an O(1) candidate
255    // file list for the common kinds (function/class/method/constant/
256    // enum-case, all keyed by `word` — see `build_maps`), so the usual case
257    // scans one file's classes instead of every class in every workspace
258    // file. Properties keyed under `bare`, `@method` doc-methods (not
259    // indexed at all), and any `decls_by_name` miss fall back to the
260    // exhaustive scan below, identical to the original behavior.
261    if let Some(refs) = wi.decls_by_name.get(&word) {
262        for r in refs {
263            if let Some((uri, idx)) = wi.files.get(r.file as usize)
264                && let Some(loc) = any_declaration_in_file(uri, idx, &word, bare)
265            {
266                return Some(loc);
267            }
268        }
269    }
270
271    for (uri, idx) in &wi.files {
272        if let Some(loc) = any_declaration_in_file(uri, idx, &word, bare) {
273            return Some(loc);
274        }
275    }
276    None
277}