php_lsp/navigation/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::document::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/// Find all concrete implementations of a METHOD across the subtypes of its
54/// declaring class/interface.
55///
56/// When the cursor sits on a method name inside an interface or abstract class,
57/// this returns the same-named method in every class that extends or implements
58/// the declaring type. Uses the workspace aggregate's `subtypes_of` reverse map
59/// for an O(subtypes) lookup instead of a full corpus walk.
60pub fn find_method_implementations_from_workspace(
61 method_name: &str,
62 declaring_class: &str,
63 wi: &crate::db::workspace_index::WorkspaceIndexData,
64) -> Vec<tower_lsp::lsp_types::Location> {
65 let mut locations = Vec::new();
66 if let Some(refs) = wi.subtypes_of.get(declaring_class) {
67 for &class_ref in refs {
68 if let Some((uri, cls)) = wi.at(class_ref)
69 && let Some(method) = cls
70 .methods
71 .iter()
72 .find(|m| m.name.as_ref() == method_name && !m.is_abstract)
73 {
74 locations.push(tower_lsp::lsp_types::Location {
75 uri: uri.clone(),
76 range: crate::text::zero_width_range(method.start_line),
77 });
78 }
79 }
80 }
81 locations.sort_by(|a, b| {
82 a.uri
83 .as_str()
84 .cmp(b.uri.as_str())
85 .then(a.range.start.line.cmp(&b.range.start.line))
86 });
87 locations.dedup_by(|a, b| a.uri == b.uri && a.range.start.line == b.range.start.line);
88 locations
89}
90
91/// Phase J — Find implementations via the salsa-memoized workspace aggregate.
92/// Uses the pre-built `subtypes_of[word]` reverse map for O(matches) lookups,
93/// with an additional pass over the FQN's `subtypes_of` entry when the caller
94/// supplied one (covers classes that wrote out the fully-qualified form in
95/// their `extends`/`implements` clause). Replaces the old
96/// `find_implementations_from_index` which walked every file's classes.
97pub fn find_implementations_from_workspace(
98 word: &str,
99 fqn: Option<&str>,
100 wi: &crate::db::workspace_index::WorkspaceIndexData,
101) -> Vec<Location> {
102 let mut locations = Vec::new();
103 let mut push_refs = |key: &str| {
104 if let Some(refs) = wi.subtypes_of.get(key) {
105 for r in refs {
106 if let Some((uri, cls)) = wi.at(*r) {
107 // Re-check with `name_matches` so a bare-name subtype_of
108 // entry survives an FQN-qualified search and vice versa.
109 let extends_match = cls
110 .parent
111 .as_deref()
112 .map(|p| name_matches(p, word, fqn))
113 .unwrap_or(false);
114 let implements_match = cls.implements.iter().any(|iface| {
115 if name_matches(iface.as_ref(), word, fqn) {
116 return true;
117 }
118 // The implements clause may use a use-import alias for `word`.
119 // e.g. `use A\B\Factory as FactoryContract` + `implements FactoryContract`
120 // → iface = "FactoryContract", word = "Factory"
121 if let Some((_, file_idx)) = wi.files.get(r.file as usize) {
122 file_idx.use_imports.iter().any(|(alias, resolved_fqn)| {
123 alias.as_ref() == iface.as_ref()
124 && crate::text::fqn_short_name(resolved_fqn) == word
125 })
126 } else {
127 false
128 }
129 });
130 if extends_match || implements_match {
131 let pos = tower_lsp::lsp_types::Position {
132 line: cls.start_line,
133 character: 0,
134 };
135 locations.push(Location {
136 uri: uri.clone(),
137 range: tower_lsp::lsp_types::Range {
138 start: pos,
139 end: pos,
140 },
141 });
142 }
143 }
144 }
145 }
146 };
147 push_refs(word);
148 if let Some(f) = fqn
149 && f != word
150 {
151 push_refs(f);
152 // Cover `\App\Animal`-style leading-backslash forms.
153 let trimmed = f.trim_start_matches('\\');
154 if trimmed != f {
155 push_refs(trimmed);
156 }
157 }
158 // De-dup: a class may list both the bare name and the FQN of the same
159 // parent (unlikely but cheap to guard against).
160 locations.sort_by(|a, b| {
161 a.uri
162 .as_str()
163 .cmp(b.uri.as_str())
164 .then(a.range.start.line.cmp(&b.range.start.line))
165 });
166 locations.dedup_by(|a, b| a.uri == b.uri && a.range.start.line == b.range.start.line);
167 locations
168}
169
170fn collect_implementations(
171 stmts: &[Stmt<'_, '_>],
172 word: &str,
173 fqn: Option<&str>,
174 sv: SourceView<'_>,
175 uri: &Url,
176 out: &mut Vec<Location>,
177) {
178 for stmt in stmts {
179 match &stmt.kind {
180 StmtKind::Class(c) => {
181 let extends_match = c
182 .extends
183 .as_ref()
184 .map(|e| name_matches(e.to_string_repr().as_ref(), word, fqn))
185 .unwrap_or(false);
186
187 let implements_match = c
188 .implements
189 .iter()
190 .any(|iface| name_matches(iface.to_string_repr().as_ref(), word, fqn));
191
192 // TODO: anonymous classes (`c.name == None`) are silently skipped.
193 // They implement interfaces but have no name to navigate to.
194 // A future fix could emit the location of the `new class` keyword instead.
195 if (extends_match || implements_match)
196 && let Some(class_name) = c.name
197 {
198 out.push(Location {
199 uri: uri.clone(),
200 range: sv.name_range_in_span(class_name.or_error(), stmt.span),
201 });
202 }
203 }
204 StmtKind::Enum(e) => {
205 let implements_match = e
206 .implements
207 .iter()
208 .any(|iface| name_matches(iface.to_string_repr().as_ref(), word, fqn));
209 if implements_match {
210 out.push(Location {
211 uri: uri.clone(),
212 range: sv.name_range_in_span(e.name.or_error(), stmt.span),
213 });
214 }
215 }
216 StmtKind::Interface(i) => {
217 let extends_match = i
218 .extends
219 .iter()
220 .any(|base| name_matches(base.to_string_repr().as_ref(), word, fqn));
221 if extends_match {
222 out.push(Location {
223 uri: uri.clone(),
224 range: sv.name_range_in_span(i.name.or_error(), stmt.span),
225 });
226 }
227 }
228 StmtKind::Namespace(ns) => {
229 if let NamespaceBody::Braced(inner) = &ns.body {
230 collect_implementations(&inner.stmts, word, fqn, sv, uri, out);
231 }
232 }
233 _ => {}
234 }
235 }
236}