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),
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),
153                    });
154                }
155            }
156            StmtKind::Namespace(ns) => {
157                if let NamespaceBody::Braced(inner) = &ns.body {
158                    collect_implementations(inner, word, fqn, sv, uri, out);
159                }
160            }
161            _ => {}
162        }
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    fn uri(path: &str) -> Url {
171        Url::parse(&format!("file://{path}")).unwrap()
172    }
173
174    fn doc(path: &str, source: &str) -> (Url, Arc<ParsedDoc>) {
175        (uri(path), Arc::new(ParsedDoc::parse(source.to_string())))
176    }
177
178    // ── find_implementations ──────────────────────────────────────────────────
179
180    #[test]
181    fn finds_class_implementing_interface() {
182        let src = "<?php\ninterface Countable {}\nclass MyList implements Countable {}";
183        let docs = vec![doc("/a.php", src)];
184        let locs = find_implementations("Countable", None, &docs);
185        assert_eq!(locs.len(), 1);
186        assert_eq!(locs[0].range.start.line, 2);
187    }
188
189    #[test]
190    fn finds_class_extending_parent() {
191        let src = "<?php\nclass Animal {}\nclass Dog extends Animal {}";
192        let docs = vec![doc("/a.php", src)];
193        let locs = find_implementations("Animal", None, &docs);
194        assert_eq!(locs.len(), 1);
195    }
196
197    #[test]
198    fn no_implementations_for_unknown_name() {
199        let src = "<?php\nclass Foo {}";
200        let docs = vec![doc("/a.php", src)];
201        let locs = find_implementations("Bar", None, &docs);
202        assert!(locs.is_empty());
203    }
204
205    #[test]
206    fn finds_across_multiple_docs() {
207        let a = doc("/a.php", "<?php\nclass DogA extends Animal {}");
208        let b = doc("/b.php", "<?php\nclass DogB extends Animal {}");
209        let locs = find_implementations("Animal", None, &[a, b]);
210        assert_eq!(locs.len(), 2);
211    }
212
213    #[test]
214    fn class_implementing_multiple_interfaces() {
215        let src = "<?php\nclass Repo implements Countable, Serializable {}";
216        let docs = vec![doc("/a.php", src)];
217        let countable = find_implementations("Countable", None, &docs);
218        let serializable = find_implementations("Serializable", None, &docs);
219        assert_eq!(countable.len(), 1);
220        assert_eq!(serializable.len(), 1);
221    }
222
223    #[test]
224    fn enum_implementing_interface_is_found() {
225        // PHP 8.1+ enums can implement interfaces.
226        let src = "<?php\ninterface HasLabel {}\nenum Status: string implements HasLabel {\n    case Active = 'active';\n}";
227        let docs = vec![doc("/a.php", src)];
228        let locs = find_implementations("HasLabel", None, &docs);
229        assert_eq!(
230            locs.len(),
231            1,
232            "expected enum Status as implementation of HasLabel, got: {:?}",
233            locs
234        );
235        assert_eq!(
236            locs[0].range.start.line, 2,
237            "enum declaration should be on line 2"
238        );
239    }
240
241    #[test]
242    fn multiple_classes_in_same_doc_all_found() {
243        // Three concrete classes all extend the same base.
244        let src = "<?php\nclass Base {}\nclass A extends Base {}\nclass B extends Base {}\nclass C extends Base {}";
245        let docs = vec![doc("/a.php", src)];
246        let locs = find_implementations("Base", None, &docs);
247        assert_eq!(locs.len(), 3);
248        let names: Vec<u32> = locs.iter().map(|l| l.range.start.line).collect();
249        assert!(names.contains(&2));
250        assert!(names.contains(&3));
251        assert!(names.contains(&4));
252    }
253
254    #[test]
255    fn class_that_extends_and_implements_produces_one_location() {
256        // `class Child extends Parent implements Iface {}` — Child satisfies both
257        // a search for "Parent" and for "Iface", but each search yields exactly
258        // one Location (not two).
259        let src = "<?php\nclass Child extends Parent implements Iface {}";
260        let docs = vec![doc("/a.php", src)];
261        assert_eq!(find_implementations("Parent", None, &docs).len(), 1);
262        assert_eq!(find_implementations("Iface", None, &docs).len(), 1);
263    }
264
265    #[test]
266    fn partial_name_match_is_not_returned() {
267        // "Animal" must not match a class named "AnimalHouse".
268        let src = "<?php\nclass AnimalHouse extends Creature {}";
269        let docs = vec![doc("/a.php", src)];
270        let locs = find_implementations("Animal", None, &docs);
271        assert!(
272            locs.is_empty(),
273            "partial name 'Animal' must not match 'AnimalHouse extends Creature'"
274        );
275    }
276
277    #[test]
278    fn empty_docs_returns_empty() {
279        let locs = find_implementations("Animal", None, &[]);
280        assert!(locs.is_empty());
281    }
282
283    #[test]
284    fn braced_namespace_class_is_found() {
285        // Classes inside `namespace Foo { ... }` (braced form) must be reachable.
286        let src = "<?php\nnamespace App {\n    class Dog extends Animal {}\n}";
287        let docs = vec![doc("/a.php", src)];
288        let locs = find_implementations("Animal", None, &docs);
289        assert_eq!(
290            locs.len(),
291            1,
292            "expected Dog inside braced namespace, got: {locs:?}"
293        );
294        assert_eq!(locs[0].range.start.line, 2);
295    }
296
297    #[test]
298    fn unbraced_namespace_class_is_found() {
299        // Classes after `namespace Foo;` (unbraced form) appear as top-level
300        // siblings in the AST and must be found without special handling.
301        let src = "<?php\nnamespace App;\nclass Dog extends Animal {}";
302        let docs = vec![doc("/a.php", src)];
303        let locs = find_implementations("Animal", None, &docs);
304        assert_eq!(
305            locs.len(),
306            1,
307            "expected Dog inside unbraced namespace, got: {locs:?}"
308        );
309        assert_eq!(locs[0].range.start.line, 2);
310    }
311
312    #[test]
313    fn fully_qualified_extends_does_not_match_without_fqn_context() {
314        // Without a resolved FQN (fqn=None), `extends \Animal` does NOT match a
315        // search for bare "Animal". This is correct: the caller must supply the
316        // FQN when it is available (via goto_implementation + file_imports).
317        let src = "<?php\nclass Dog extends \\Animal {}";
318        let docs = vec![doc("/a.php", src)];
319        let locs = find_implementations("Animal", None, &docs);
320        assert!(
321            locs.is_empty(),
322            "without FQN context, '\\\\Animal' must not match bare 'Animal'"
323        );
324    }
325
326    #[test]
327    fn fqn_context_finds_fully_qualified_extends() {
328        // With fqn=Some("App\\Animal"), `extends \App\Animal` IS found.
329        let src = "<?php\nclass Dog extends \\App\\Animal {}";
330        let docs = vec![doc("/a.php", src)];
331        let locs = find_implementations("Animal", Some("App\\Animal"), &docs);
332        assert_eq!(
333            locs.len(),
334            1,
335            "FQN-aware search must find 'extends \\\\App\\\\Animal', got: {locs:?}"
336        );
337    }
338
339    #[test]
340    fn fqn_context_finds_qualified_extends_without_leading_backslash() {
341        // `extends App\Animal` (no leading `\`) is also matched by the FQN.
342        let src = "<?php\nclass Dog extends App\\Animal {}";
343        let docs = vec![doc("/a.php", src)];
344        let locs = find_implementations("Animal", Some("App\\Animal"), &docs);
345        assert_eq!(
346            locs.len(),
347            1,
348            "FQN-aware search must find 'extends App\\\\Animal', got: {locs:?}"
349        );
350    }
351
352    #[test]
353    fn fqn_context_still_matches_short_name_form() {
354        // When fqn is provided, the bare short-name form is still matched so that
355        // classes in the same namespace (which write `extends Animal`) are included.
356        let src = "<?php\nclass Dog extends Animal {}";
357        let docs = vec![doc("/a.php", src)];
358        let locs = find_implementations("Animal", Some("App\\Animal"), &docs);
359        assert_eq!(
360            locs.len(),
361            1,
362            "short-name form must still match when FQN is provided, got: {locs:?}"
363        );
364    }
365
366    #[test]
367    fn anonymous_class_does_not_cause_panic() {
368        // Anonymous classes have no name (c.name == None) and must be skipped
369        // silently without panicking.
370        let src = "<?php\n$x = new class extends Animal {};";
371        let docs = vec![doc("/a.php", src)];
372        // We only care that this doesn't panic; anonymous classes have no name
373        // to report a Location for.
374        let _ = find_implementations("Animal", None, &docs);
375    }
376
377    #[test]
378    fn location_uri_matches_source_doc() {
379        let a = doc("/src/Dog.php", "<?php\nclass Dog extends Animal {}");
380        let b = doc("/src/Cat.php", "<?php\nclass Cat extends Animal {}");
381        let locs = find_implementations("Animal", None, &[a, b]);
382        assert_eq!(locs.len(), 2);
383        let uris: Vec<&str> = locs.iter().map(|l| l.uri.path()).collect();
384        assert!(uris.contains(&"/src/Dog.php"));
385        assert!(uris.contains(&"/src/Cat.php"));
386    }
387
388    // ── find_implementations_from_workspace ──────────────────────────────────
389    //
390    // Phase J: these tests build a `WorkspaceIndexData` directly via
391    // `from_files` (no AnalysisHost needed) so they exercise the reverse-map
392    // shape the backend actually uses in production.
393
394    fn make_index(path: &str, src: &str) -> (Url, std::sync::Arc<crate::file_index::FileIndex>) {
395        use crate::file_index::FileIndex;
396        let u = uri(path);
397        let d = ParsedDoc::parse(src.to_string());
398        (u.clone(), std::sync::Arc::new(FileIndex::extract(&d)))
399    }
400
401    #[test]
402    fn from_workspace_finds_implementing_class() {
403        let (circle_uri, circle_idx) = make_index(
404            "/circle.php",
405            "<?php\nclass Circle implements Drawable {\n    public function draw(): void {}\n}",
406        );
407        let wi = crate::db::workspace_index::WorkspaceIndexData::from_files(vec![(
408            circle_uri.clone(),
409            circle_idx,
410        )]);
411        let locs = find_implementations_from_workspace("Drawable", None, &wi);
412        assert_eq!(
413            locs.len(),
414            1,
415            "expected Circle as implementation of Drawable"
416        );
417        assert_eq!(locs[0].uri, circle_uri);
418        assert_eq!(locs[0].range.start.line, 1, "Circle is declared on line 1");
419    }
420
421    #[test]
422    fn from_workspace_finds_extending_class() {
423        let (dog_uri, dog_idx) = make_index("/dog.php", "<?php\nclass Dog extends Animal {}");
424        let wi =
425            crate::db::workspace_index::WorkspaceIndexData::from_files(vec![(dog_uri, dog_idx)]);
426        let locs = find_implementations_from_workspace("Animal", None, &wi);
427        assert_eq!(locs.len(), 1, "expected Dog as subclass of Animal");
428        assert_eq!(locs[0].range.start.line, 1);
429    }
430
431    #[test]
432    fn from_workspace_finds_across_multiple_files() {
433        let (a_uri, a_idx) = make_index("/a.php", "<?php\nclass Cat extends Animal {}");
434        let (b_uri, b_idx) = make_index("/b.php", "<?php\nclass Dog extends Animal {}");
435        let wi = crate::db::workspace_index::WorkspaceIndexData::from_files(vec![
436            (a_uri, a_idx),
437            (b_uri, b_idx),
438        ]);
439        let locs = find_implementations_from_workspace("Animal", None, &wi);
440        assert_eq!(locs.len(), 2, "expected both Cat and Dog");
441    }
442}