Skip to main content

php_lsp/
implementation.rs

1/// `textDocument/implementation` — find all classes that implement an interface
2/// or extend a class with the given name.
3use 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/// Returns `true` when the name written in an `extends`/`implements` clause
11/// (given as its `to_string_repr()` string) refers to the symbol we are
12/// searching for.
13///
14/// Two forms are accepted:
15/// - Short-name match: `repr == word`
16///   Covers the common case where both files use the same unqualified name.
17/// - FQN match: `repr` (with any leading `\` stripped) `== fqn`
18///   Covers files that write the fully-qualified form (`\App\Animal` or
19///   `App\Animal`) while the cursor file imports the class with a `use`
20///   statement and the cursor sits on the short alias.
21#[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
26/// Return all `Location`s where a class declares `extends Name` or
27/// `implements Name`.
28///
29/// `fqn` is the fully-qualified name of the symbol (e.g. `"App\\Animal"`),
30/// resolved from the calling file's `use` imports. When provided, extends/
31/// implements clauses that spell out the FQN form (`\App\Animal` or
32/// `App\Animal`) are also matched, in addition to the bare `word`.
33pub 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
46/// Phase J — Find implementations via the salsa-memoized workspace aggregate.
47/// Uses the pre-built `subtypes_of[word]` reverse map for O(matches) lookups,
48/// with an additional pass over the FQN's `subtypes_of` entry when the caller
49/// supplied one (covers classes that wrote out the fully-qualified form in
50/// their `extends`/`implements` clause). Replaces the old
51/// `find_implementations_from_index` which walked every file's classes.
52pub 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                    // Re-check with `name_matches` so a bare-name subtype_of
63                    // entry survives an FQN-qualified search and vice versa.
64                    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        // Cover `\App\Animal`-style leading-backslash forms.
96        let trimmed = f.trim_start_matches('\\');
97        if trimmed != f {
98            push_refs(trimmed);
99        }
100    }
101    // De-dup: a class may list both the bare name and the FQN of the same
102    // parent (unlikely but cheap to guard against).
103    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    // ── find_implementations ──────────────────────────────────────────────────
191
192    #[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        // PHP 8.1+ enums can implement interfaces.
238        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        // Three concrete classes all extend the same base.
256        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        // `class Child extends Parent implements Iface {}` — Child satisfies both
269        // a search for "Parent" and for "Iface", but each search yields exactly
270        // one Location (not two).
271        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        // "Animal" must not match a class named "AnimalHouse".
280        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        // Classes inside `namespace Foo { ... }` (braced form) must be reachable.
298        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        // Classes after `namespace Foo;` (unbraced form) appear as top-level
312        // siblings in the AST and must be found without special handling.
313        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        // Without a resolved FQN (fqn=None), `extends \Animal` does NOT match a
327        // search for bare "Animal". This is correct: the caller must supply the
328        // FQN when it is available (via goto_implementation + file_imports).
329        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        // With fqn=Some("App\\Animal"), `extends \App\Animal` IS found.
341        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        // `extends App\Animal` (no leading `\`) is also matched by the FQN.
354        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        // When fqn is provided, the bare short-name form is still matched so that
367        // classes in the same namespace (which write `extends Animal`) are included.
368        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        // Anonymous classes have no name (c.name.to_string() == None) and must be skipped
381        // silently without panicking.
382        let src = "<?php\n$x = new class extends Animal {};";
383        let docs = vec![doc("/a.php", src)];
384        // We only care that this doesn't panic; anonymous classes have no name
385        // to report a Location for.
386        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    // ── find_implementations_from_workspace ──────────────────────────────────
401    //
402    // Phase J: these tests build a `WorkspaceIndexData` directly via
403    // `from_files` (no AnalysisHost needed) so they exercise the reverse-map
404    // shape the backend actually uses in production.
405
406    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}