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/// Three 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/// - Global-namespace backslash match: `repr.trim_start_matches('\\') == word`
22/// when `fqn` is `None` and `word` has no namespace separator.
23/// Covers the case where a class writes `extends \Animal` (explicit global-
24/// namespace form) and the cursor sits on a global-namespace `Animal`
25/// interface with no `use` import.
26#[inline]
27fn name_matches(repr: &str, word: &str, fqn: Option<&str>) -> bool {
28 repr == word
29 || fqn.is_some_and(|f| repr.trim_start_matches('\\') == f)
30 || (fqn.is_none() && !word.contains('\\') && repr.trim_start_matches('\\') == word)
31}
32
33/// Return all `Location`s where a class declares `extends Name` or
34/// `implements Name`.
35///
36/// `fqn` is the fully-qualified name of the symbol (e.g. `"App\\Animal"`),
37/// resolved from the calling file's `use` imports. When provided, extends/
38/// implements clauses that spell out the FQN form (`\App\Animal` or
39/// `App\Animal`) are also matched, in addition to the bare `word`.
40pub fn find_implementations(
41 word: &str,
42 fqn: Option<&str>,
43 all_docs: &[(Url, Arc<ParsedDoc>)],
44) -> Vec<Location> {
45 let mut locations = Vec::new();
46 for (uri, doc) in all_docs {
47 let sv = doc.view();
48 collect_implementations(&doc.program().stmts, word, fqn, sv, uri, &mut locations);
49 }
50 locations
51}
52
53/// Phase J — Find implementations via the salsa-memoized workspace aggregate.
54/// Uses the pre-built `subtypes_of[word]` reverse map for O(matches) lookups,
55/// with an additional pass over the FQN's `subtypes_of` entry when the caller
56/// supplied one (covers classes that wrote out the fully-qualified form in
57/// their `extends`/`implements` clause). Replaces the old
58/// `find_implementations_from_index` which walked every file's classes.
59pub fn find_implementations_from_workspace(
60 word: &str,
61 fqn: Option<&str>,
62 wi: &crate::db::workspace_index::WorkspaceIndexData,
63) -> Vec<Location> {
64 let mut locations = Vec::new();
65 let mut push_refs = |key: &str| {
66 if let Some(refs) = wi.subtypes_of.get(key) {
67 for r in refs {
68 if let Some((uri, cls)) = wi.at(*r) {
69 // Re-check with `name_matches` so a bare-name subtype_of
70 // entry survives an FQN-qualified search and vice versa.
71 let extends_match = cls
72 .parent
73 .as_deref()
74 .map(|p| name_matches(p, word, fqn))
75 .unwrap_or(false);
76 let implements_match = cls
77 .implements
78 .iter()
79 .any(|iface| name_matches(iface.as_ref(), word, fqn));
80 if extends_match || implements_match {
81 let pos = tower_lsp::lsp_types::Position {
82 line: cls.start_line,
83 character: 0,
84 };
85 locations.push(Location {
86 uri: uri.clone(),
87 range: tower_lsp::lsp_types::Range {
88 start: pos,
89 end: pos,
90 },
91 });
92 }
93 }
94 }
95 }
96 };
97 push_refs(word);
98 if let Some(f) = fqn
99 && f != word
100 {
101 push_refs(f);
102 // Cover `\App\Animal`-style leading-backslash forms.
103 let trimmed = f.trim_start_matches('\\');
104 if trimmed != f {
105 push_refs(trimmed);
106 }
107 }
108 // De-dup: a class may list both the bare name and the FQN of the same
109 // parent (unlikely but cheap to guard against).
110 locations.sort_by(|a, b| {
111 a.uri
112 .as_str()
113 .cmp(b.uri.as_str())
114 .then(a.range.start.line.cmp(&b.range.start.line))
115 });
116 locations.dedup_by(|a, b| a.uri == b.uri && a.range.start.line == b.range.start.line);
117 locations
118}
119
120fn collect_implementations(
121 stmts: &[Stmt<'_, '_>],
122 word: &str,
123 fqn: Option<&str>,
124 sv: SourceView<'_>,
125 uri: &Url,
126 out: &mut Vec<Location>,
127) {
128 for stmt in stmts {
129 match &stmt.kind {
130 StmtKind::Class(c) => {
131 let extends_match = c
132 .extends
133 .as_ref()
134 .map(|e| name_matches(e.to_string_repr().as_ref(), word, fqn))
135 .unwrap_or(false);
136
137 let implements_match = c
138 .implements
139 .iter()
140 .any(|iface| name_matches(iface.to_string_repr().as_ref(), word, fqn));
141
142 // TODO: anonymous classes (`c.name == None`) are silently skipped.
143 // They implement interfaces but have no name to navigate to.
144 // A future fix could emit the location of the `new class` keyword instead.
145 if (extends_match || implements_match)
146 && let Some(class_name) = c.name
147 {
148 out.push(Location {
149 uri: uri.clone(),
150 range: sv.name_range(&class_name.to_string()),
151 });
152 }
153 }
154 StmtKind::Enum(e) => {
155 let implements_match = e
156 .implements
157 .iter()
158 .any(|iface| name_matches(iface.to_string_repr().as_ref(), word, fqn));
159 if implements_match {
160 out.push(Location {
161 uri: uri.clone(),
162 range: sv.name_range(&e.name.to_string()),
163 });
164 }
165 }
166 StmtKind::Interface(i) => {
167 let extends_match = i
168 .extends
169 .iter()
170 .any(|base| name_matches(base.to_string_repr().as_ref(), word, fqn));
171 if extends_match {
172 out.push(Location {
173 uri: uri.clone(),
174 range: sv.name_range(&i.name.to_string()),
175 });
176 }
177 }
178 StmtKind::Namespace(ns) => {
179 if let NamespaceBody::Braced(inner) = &ns.body {
180 collect_implementations(&inner.stmts, word, fqn, sv, uri, out);
181 }
182 }
183 _ => {}
184 }
185 }
186}