1use std::sync::Arc;
4
5use php_ast::{NamespaceBody, Stmt, StmtKind};
6use tower_lsp::lsp_types::{Location, Url};
7
8use crate::ast::{ParsedDoc, SourceView};
9
10#[inline]
22fn name_matches(repr: &str, word: &str, fqn: Option<&str>) -> bool {
23 repr == word || fqn.is_some_and(|f| repr.trim_start_matches('\\') == f)
24}
25
26pub fn find_implementations(
34 word: &str,
35 fqn: Option<&str>,
36 all_docs: &[(Url, Arc<ParsedDoc>)],
37) -> Vec<Location> {
38 let mut locations = Vec::new();
39 for (uri, doc) in all_docs {
40 let sv = doc.view();
41 collect_implementations(&doc.program().stmts, word, fqn, sv, uri, &mut locations);
42 }
43 locations
44}
45
46pub fn find_implementations_from_workspace(
53 word: &str,
54 fqn: Option<&str>,
55 wi: &crate::db::workspace_index::WorkspaceIndexData,
56) -> Vec<Location> {
57 let mut locations = Vec::new();
58 let mut push_refs = |key: &str| {
59 if let Some(refs) = wi.subtypes_of.get(key) {
60 for r in refs {
61 if let Some((uri, cls)) = wi.at(*r) {
62 let extends_match = cls
65 .parent
66 .as_deref()
67 .map(|p| name_matches(p, word, fqn))
68 .unwrap_or(false);
69 let implements_match = cls
70 .implements
71 .iter()
72 .any(|iface| name_matches(iface.as_ref(), word, fqn));
73 if extends_match || implements_match {
74 let pos = tower_lsp::lsp_types::Position {
75 line: cls.start_line,
76 character: 0,
77 };
78 locations.push(Location {
79 uri: uri.clone(),
80 range: tower_lsp::lsp_types::Range {
81 start: pos,
82 end: pos,
83 },
84 });
85 }
86 }
87 }
88 }
89 };
90 push_refs(word);
91 if let Some(f) = fqn
92 && f != word
93 {
94 push_refs(f);
95 let trimmed = f.trim_start_matches('\\');
97 if trimmed != f {
98 push_refs(trimmed);
99 }
100 }
101 locations.sort_by(|a, b| {
104 a.uri
105 .as_str()
106 .cmp(b.uri.as_str())
107 .then(a.range.start.line.cmp(&b.range.start.line))
108 });
109 locations.dedup_by(|a, b| a.uri == b.uri && a.range.start.line == b.range.start.line);
110 locations
111}
112
113fn collect_implementations(
114 stmts: &[Stmt<'_, '_>],
115 word: &str,
116 fqn: Option<&str>,
117 sv: SourceView<'_>,
118 uri: &Url,
119 out: &mut Vec<Location>,
120) {
121 for stmt in stmts {
122 match &stmt.kind {
123 StmtKind::Class(c) => {
124 let extends_match = c
125 .extends
126 .as_ref()
127 .map(|e| name_matches(e.to_string_repr().as_ref(), word, fqn))
128 .unwrap_or(false);
129
130 let implements_match = c
131 .implements
132 .iter()
133 .any(|iface| name_matches(iface.to_string_repr().as_ref(), word, fqn));
134
135 if (extends_match || implements_match)
136 && let Some(class_name) = c.name
137 {
138 out.push(Location {
139 uri: uri.clone(),
140 range: sv.name_range(&class_name.to_string()),
141 });
142 }
143 }
144 StmtKind::Enum(e) => {
145 let implements_match = e
146 .implements
147 .iter()
148 .any(|iface| name_matches(iface.to_string_repr().as_ref(), word, fqn));
149 if implements_match {
150 out.push(Location {
151 uri: uri.clone(),
152 range: sv.name_range(&e.name.to_string()),
153 });
154 }
155 }
156 StmtKind::Interface(i) => {
157 let extends_match = i
158 .extends
159 .iter()
160 .any(|base| name_matches(base.to_string_repr().as_ref(), word, fqn));
161 if extends_match {
162 out.push(Location {
163 uri: uri.clone(),
164 range: sv.name_range(&i.name.to_string()),
165 });
166 }
167 }
168 StmtKind::Namespace(ns) => {
169 if let NamespaceBody::Braced(inner) = &ns.body {
170 collect_implementations(inner, word, fqn, sv, uri, out);
171 }
172 }
173 _ => {}
174 }
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 fn uri(path: &str) -> Url {
183 Url::parse(&format!("file://{path}")).unwrap()
184 }
185
186 fn doc(path: &str, source: &str) -> (Url, Arc<ParsedDoc>) {
187 (uri(path), Arc::new(ParsedDoc::parse(source.to_string())))
188 }
189
190 #[test]
193 fn finds_class_implementing_interface() {
194 let src = "<?php\ninterface Countable {}\nclass MyList implements Countable {}";
195 let docs = vec![doc("/a.php", src)];
196 let locs = find_implementations("Countable", None, &docs);
197 assert_eq!(locs.len(), 1);
198 assert_eq!(locs[0].range.start.line, 2);
199 }
200
201 #[test]
202 fn finds_class_extending_parent() {
203 let src = "<?php\nclass Animal {}\nclass Dog extends Animal {}";
204 let docs = vec![doc("/a.php", src)];
205 let locs = find_implementations("Animal", None, &docs);
206 assert_eq!(locs.len(), 1);
207 }
208
209 #[test]
210 fn no_implementations_for_unknown_name() {
211 let src = "<?php\nclass Foo {}";
212 let docs = vec![doc("/a.php", src)];
213 let locs = find_implementations("Bar", None, &docs);
214 assert!(locs.is_empty());
215 }
216
217 #[test]
218 fn finds_across_multiple_docs() {
219 let a = doc("/a.php", "<?php\nclass DogA extends Animal {}");
220 let b = doc("/b.php", "<?php\nclass DogB extends Animal {}");
221 let locs = find_implementations("Animal", None, &[a, b]);
222 assert_eq!(locs.len(), 2);
223 }
224
225 #[test]
226 fn class_implementing_multiple_interfaces() {
227 let src = "<?php\nclass Repo implements Countable, Serializable {}";
228 let docs = vec![doc("/a.php", src)];
229 let countable = find_implementations("Countable", None, &docs);
230 let serializable = find_implementations("Serializable", None, &docs);
231 assert_eq!(countable.len(), 1);
232 assert_eq!(serializable.len(), 1);
233 }
234
235 #[test]
236 fn enum_implementing_interface_is_found() {
237 let src = "<?php\ninterface HasLabel {}\nenum Status: string implements HasLabel {\n case Active = 'active';\n}";
239 let docs = vec![doc("/a.php", src)];
240 let locs = find_implementations("HasLabel", None, &docs);
241 assert_eq!(
242 locs.len(),
243 1,
244 "expected enum Status as implementation of HasLabel, got: {:?}",
245 locs
246 );
247 assert_eq!(
248 locs[0].range.start.line, 2,
249 "enum declaration should be on line 2"
250 );
251 }
252
253 #[test]
254 fn multiple_classes_in_same_doc_all_found() {
255 let src = "<?php\nclass Base {}\nclass A extends Base {}\nclass B extends Base {}\nclass C extends Base {}";
257 let docs = vec![doc("/a.php", src)];
258 let locs = find_implementations("Base", None, &docs);
259 assert_eq!(locs.len(), 3);
260 let names: Vec<u32> = locs.iter().map(|l| l.range.start.line).collect();
261 assert!(names.contains(&2));
262 assert!(names.contains(&3));
263 assert!(names.contains(&4));
264 }
265
266 #[test]
267 fn class_that_extends_and_implements_produces_one_location() {
268 let src = "<?php\nclass Child extends Parent implements Iface {}";
272 let docs = vec![doc("/a.php", src)];
273 assert_eq!(find_implementations("Parent", None, &docs).len(), 1);
274 assert_eq!(find_implementations("Iface", None, &docs).len(), 1);
275 }
276
277 #[test]
278 fn partial_name_match_is_not_returned() {
279 let src = "<?php\nclass AnimalHouse extends Creature {}";
281 let docs = vec![doc("/a.php", src)];
282 let locs = find_implementations("Animal", None, &docs);
283 assert!(
284 locs.is_empty(),
285 "partial name 'Animal' must not match 'AnimalHouse extends Creature'"
286 );
287 }
288
289 #[test]
290 fn empty_docs_returns_empty() {
291 let locs = find_implementations("Animal", None, &[]);
292 assert!(locs.is_empty());
293 }
294
295 #[test]
296 fn braced_namespace_class_is_found() {
297 let src = "<?php\nnamespace App {\n class Dog extends Animal {}\n}";
299 let docs = vec![doc("/a.php", src)];
300 let locs = find_implementations("Animal", None, &docs);
301 assert_eq!(
302 locs.len(),
303 1,
304 "expected Dog inside braced namespace, got: {locs:?}"
305 );
306 assert_eq!(locs[0].range.start.line, 2);
307 }
308
309 #[test]
310 fn unbraced_namespace_class_is_found() {
311 let src = "<?php\nnamespace App;\nclass Dog extends Animal {}";
314 let docs = vec![doc("/a.php", src)];
315 let locs = find_implementations("Animal", None, &docs);
316 assert_eq!(
317 locs.len(),
318 1,
319 "expected Dog inside unbraced namespace, got: {locs:?}"
320 );
321 assert_eq!(locs[0].range.start.line, 2);
322 }
323
324 #[test]
325 fn fully_qualified_extends_does_not_match_without_fqn_context() {
326 let src = "<?php\nclass Dog extends \\Animal {}";
330 let docs = vec![doc("/a.php", src)];
331 let locs = find_implementations("Animal", None, &docs);
332 assert!(
333 locs.is_empty(),
334 "without FQN context, '\\\\Animal' must not match bare 'Animal'"
335 );
336 }
337
338 #[test]
339 fn fqn_context_finds_fully_qualified_extends() {
340 let src = "<?php\nclass Dog extends \\App\\Animal {}";
342 let docs = vec![doc("/a.php", src)];
343 let locs = find_implementations("Animal", Some("App\\Animal"), &docs);
344 assert_eq!(
345 locs.len(),
346 1,
347 "FQN-aware search must find 'extends \\\\App\\\\Animal', got: {locs:?}"
348 );
349 }
350
351 #[test]
352 fn fqn_context_finds_qualified_extends_without_leading_backslash() {
353 let src = "<?php\nclass Dog extends App\\Animal {}";
355 let docs = vec![doc("/a.php", src)];
356 let locs = find_implementations("Animal", Some("App\\Animal"), &docs);
357 assert_eq!(
358 locs.len(),
359 1,
360 "FQN-aware search must find 'extends App\\\\Animal', got: {locs:?}"
361 );
362 }
363
364 #[test]
365 fn fqn_context_still_matches_short_name_form() {
366 let src = "<?php\nclass Dog extends Animal {}";
369 let docs = vec![doc("/a.php", src)];
370 let locs = find_implementations("Animal", Some("App\\Animal"), &docs);
371 assert_eq!(
372 locs.len(),
373 1,
374 "short-name form must still match when FQN is provided, got: {locs:?}"
375 );
376 }
377
378 #[test]
379 fn anonymous_class_does_not_cause_panic() {
380 let src = "<?php\n$x = new class extends Animal {};";
383 let docs = vec![doc("/a.php", src)];
384 let _ = find_implementations("Animal", None, &docs);
387 }
388
389 #[test]
390 fn location_uri_matches_source_doc() {
391 let a = doc("/src/Dog.php", "<?php\nclass Dog extends Animal {}");
392 let b = doc("/src/Cat.php", "<?php\nclass Cat extends Animal {}");
393 let locs = find_implementations("Animal", None, &[a, b]);
394 assert_eq!(locs.len(), 2);
395 let uris: Vec<&str> = locs.iter().map(|l| l.uri.path()).collect();
396 assert!(uris.contains(&"/src/Dog.php"));
397 assert!(uris.contains(&"/src/Cat.php"));
398 }
399
400 fn make_index(path: &str, src: &str) -> (Url, std::sync::Arc<crate::file_index::FileIndex>) {
407 use crate::file_index::FileIndex;
408 let u = uri(path);
409 let d = ParsedDoc::parse(src.to_string());
410 (u.clone(), std::sync::Arc::new(FileIndex::extract(&d)))
411 }
412
413 #[test]
414 fn from_workspace_finds_implementing_class() {
415 let (circle_uri, circle_idx) = make_index(
416 "/circle.php",
417 "<?php\nclass Circle implements Drawable {\n public function draw(): void {}\n}",
418 );
419 let wi = crate::db::workspace_index::WorkspaceIndexData::from_files(vec![(
420 circle_uri.clone(),
421 circle_idx,
422 )]);
423 let locs = find_implementations_from_workspace("Drawable", None, &wi);
424 assert_eq!(
425 locs.len(),
426 1,
427 "expected Circle as implementation of Drawable"
428 );
429 assert_eq!(locs[0].uri, circle_uri);
430 assert_eq!(locs[0].range.start.line, 1, "Circle is declared on line 1");
431 }
432
433 #[test]
434 fn from_workspace_finds_extending_class() {
435 let (dog_uri, dog_idx) = make_index("/dog.php", "<?php\nclass Dog extends Animal {}");
436 let wi =
437 crate::db::workspace_index::WorkspaceIndexData::from_files(vec![(dog_uri, dog_idx)]);
438 let locs = find_implementations_from_workspace("Animal", None, &wi);
439 assert_eq!(locs.len(), 1, "expected Dog as subclass of Animal");
440 assert_eq!(locs[0].range.start.line, 1);
441 }
442
443 #[test]
444 fn from_workspace_finds_across_multiple_files() {
445 let (a_uri, a_idx) = make_index("/a.php", "<?php\nclass Cat extends Animal {}");
446 let (b_uri, b_idx) = make_index("/b.php", "<?php\nclass Dog extends Animal {}");
447 let wi = crate::db::workspace_index::WorkspaceIndexData::from_files(vec![
448 (a_uri, a_idx),
449 (b_uri, b_idx),
450 ]);
451 let locs = find_implementations_from_workspace("Animal", None, &wi);
452 assert_eq!(locs.len(), 2, "expected both Cat and Dog");
453 }
454}