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}