Skip to main content

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::{ExprKind, 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).
96pub fn find_implementations_from_workspace(
97    word: &str,
98    fqn: Option<&str>,
99    wi: &crate::db::workspace_index::WorkspaceIndexData,
100) -> Vec<Location> {
101    let mut locations = Vec::new();
102    let mut push_refs = |key: &str| {
103        if let Some(refs) = wi.subtypes_of.get(key) {
104            for r in refs {
105                if let Some((uri, cls)) = wi.at(*r) {
106                    // Re-check with `name_matches` so a bare-name subtype_of
107                    // entry survives an FQN-qualified search and vice versa.
108                    let extends_match = cls
109                        .parent
110                        .as_deref()
111                        .map(|p| name_matches(p, word, fqn))
112                        .unwrap_or(false);
113                    let implements_match = cls.implements.iter().any(|iface| {
114                        if name_matches(iface.as_ref(), word, fqn) {
115                            return true;
116                        }
117                        // The implements clause may use a use-import alias for `word`.
118                        // e.g. `use A\B\Factory as FactoryContract` + `implements FactoryContract`
119                        // → iface = "FactoryContract", word = "Factory"
120                        if let Some((_, file_idx)) = wi.files.get(r.file as usize) {
121                            file_idx.use_imports.iter().any(|(alias, resolved_fqn)| {
122                                alias.as_ref() == iface.as_ref()
123                                    && crate::text::fqn_short_name(resolved_fqn) == word
124                            })
125                        } else {
126                            false
127                        }
128                    });
129                    if extends_match || implements_match {
130                        let pos = tower_lsp::lsp_types::Position {
131                            line: cls.start_line,
132                            character: 0,
133                        };
134                        locations.push(Location {
135                            uri: uri.clone(),
136                            range: tower_lsp::lsp_types::Range {
137                                start: pos,
138                                end: pos,
139                            },
140                        });
141                    }
142                }
143            }
144        }
145    };
146    push_refs(word);
147    if let Some(f) = fqn
148        && f != word
149    {
150        push_refs(f);
151        // Cover `\App\Animal`-style leading-backslash forms.
152        let trimmed = f.trim_start_matches('\\');
153        if trimmed != f {
154            push_refs(trimmed);
155        }
156    }
157    // De-dup: a class may list both the bare name and the FQN of the same
158    // parent (unlikely but cheap to guard against).
159    locations.sort_by(|a, b| {
160        a.uri
161            .as_str()
162            .cmp(b.uri.as_str())
163            .then(a.range.start.line.cmp(&b.range.start.line))
164    });
165    locations.dedup_by(|a, b| a.uri == b.uri && a.range.start.line == b.range.start.line);
166    locations
167}
168
169fn collect_implementations(
170    stmts: &[Stmt<'_, '_>],
171    word: &str,
172    fqn: Option<&str>,
173    sv: SourceView<'_>,
174    uri: &Url,
175    out: &mut Vec<Location>,
176) {
177    for stmt in stmts {
178        match &stmt.kind {
179            StmtKind::Class(c) => {
180                let extends_match = c
181                    .extends
182                    .as_ref()
183                    .map(|e| name_matches(e.to_string_repr().as_ref(), word, fqn))
184                    .unwrap_or(false);
185
186                let implements_match = c
187                    .implements
188                    .iter()
189                    .any(|iface| name_matches(iface.to_string_repr().as_ref(), word, fqn));
190
191                if extends_match || implements_match {
192                    let range = if let Some(class_name) = c.name {
193                        sv.name_range_in_span(class_name.or_error(), stmt.span)
194                    } else {
195                        // Anonymous class (`new class {}`): point to the `class` keyword.
196                        sv.name_range_in_span("class", stmt.span)
197                    };
198                    out.push(Location {
199                        uri: uri.clone(),
200                        range,
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::Expression(expr) => {
229                collect_anon_class_in_expr(expr, word, fqn, sv, stmt.span, uri, out);
230            }
231            StmtKind::Namespace(ns) => {
232                if let NamespaceBody::Braced(inner) = &ns.body {
233                    collect_implementations(&inner.stmts, word, fqn, sv, uri, out);
234                }
235            }
236            _ => {}
237        }
238    }
239}
240
241/// Recurse into an expression to find `new class {}` anonymous class declarations
242/// that implement or extend the target interface/class.
243fn collect_anon_class_in_expr(
244    expr: &php_ast::Expr<'_, '_>,
245    word: &str,
246    fqn: Option<&str>,
247    sv: SourceView<'_>,
248    stmt_span: php_ast::Span,
249    uri: &Url,
250    out: &mut Vec<Location>,
251) {
252    match &expr.kind {
253        ExprKind::AnonymousClass(c) => {
254            let extends_match = c
255                .extends
256                .as_ref()
257                .map(|e| name_matches(e.to_string_repr().as_ref(), word, fqn))
258                .unwrap_or(false);
259            let implements_match = c
260                .implements
261                .iter()
262                .any(|iface| name_matches(iface.to_string_repr().as_ref(), word, fqn));
263            if extends_match || implements_match {
264                // Emit the `class` keyword within the expression span as the location.
265                out.push(Location {
266                    uri: uri.clone(),
267                    range: sv.name_range_in_span("class", stmt_span),
268                });
269            }
270        }
271        ExprKind::New(n) => {
272            collect_anon_class_in_expr(n.class, word, fqn, sv, stmt_span, uri, out);
273        }
274        ExprKind::Assign(a) => {
275            collect_anon_class_in_expr(a.value, word, fqn, sv, stmt_span, uri, out);
276        }
277        _ => {}
278    }
279}