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::collections::HashSet;
4use std::sync::Arc;
5
6use php_ast::{ExprKind, NamespaceBody, Stmt, StmtKind};
7use tower_lsp::lsp_types::{Location, Url};
8
9use crate::document::ast::{ParsedDoc, SourceView};
10
11/// Returns `true` when the name written in an `extends`/`implements` clause
12/// (given as its `to_string_repr()` string) refers to the symbol we are
13/// searching for.
14///
15/// Three forms are accepted:
16/// - Short-name match: `repr == word`
17///   Covers the common case where both files use the same unqualified name.
18/// - FQN match: `repr` (with any leading `\` stripped) `== fqn`
19///   Covers files that write the fully-qualified form (`\App\Animal` or
20///   `App\Animal`) while the cursor file imports the class with a `use`
21///   statement and the cursor sits on the short alias.
22/// - Global-namespace backslash match: `repr.trim_start_matches('\\') == word`
23///   when `fqn` is `None` and `word` has no namespace separator.
24///   Covers the case where a class writes `extends \Animal` (explicit global-
25///   namespace form) and the cursor sits on a global-namespace `Animal`
26///   interface with no `use` import.
27#[inline]
28pub(crate) fn name_matches(repr: &str, word: &str, fqn: Option<&str>) -> bool {
29    repr == word
30        || fqn.is_some_and(|f| repr.trim_start_matches('\\') == f)
31        || (fqn.is_none() && !word.contains('\\') && repr.trim_start_matches('\\') == word)
32}
33
34/// Return all `Location`s where a class declares `extends Name` or
35/// `implements Name`.
36///
37/// `fqn` is the fully-qualified name of the symbol (e.g. `"App\\Animal"`),
38/// resolved from the calling file's `use` imports. When provided, extends/
39/// implements clauses that spell out the FQN form (`\App\Animal` or
40/// `App\Animal`) are also matched, in addition to the bare `word`.
41pub fn find_implementations(
42    word: &str,
43    fqn: Option<&str>,
44    all_docs: &[(Url, Arc<ParsedDoc>)],
45) -> Vec<Location> {
46    let mut locations = Vec::new();
47    for (uri, doc) in all_docs {
48        let sv = doc.view();
49        collect_implementations(&doc.program().stmts, word, fqn, sv, uri, &mut locations);
50    }
51    locations
52}
53
54/// Find all concrete implementations of a METHOD across the subtypes of its
55/// declaring class/interface.
56///
57/// When the cursor sits on a method name inside an interface or abstract class,
58/// this returns the same-named method in every class that extends or implements
59/// the declaring type. Uses the workspace aggregate's `subtypes_of` reverse map
60/// for an O(subtypes) lookup instead of a full corpus walk.
61pub fn find_method_implementations_from_workspace(
62    method_name: &str,
63    declaring_class: &str,
64    wi: &crate::db::workspace_index::WorkspaceIndexData,
65) -> Vec<tower_lsp::lsp_types::Location> {
66    let mut locations = Vec::new();
67    if let Some(refs) = wi.subtypes_of.get(declaring_class) {
68        for &class_ref in refs {
69            if let Some((uri, cls)) = wi.at(class_ref)
70                && let Some(method) = cls
71                    .methods
72                    .iter()
73                    .find(|m| m.name.as_ref() == method_name && !m.is_abstract)
74            {
75                locations.push(tower_lsp::lsp_types::Location {
76                    uri: uri.clone(),
77                    range: crate::text::zero_width_range(method.start_line),
78                });
79            }
80        }
81    }
82    locations.sort_by(|a, b| {
83        a.uri
84            .as_str()
85            .cmp(b.uri.as_str())
86            .then(a.range.start.line.cmp(&b.range.start.line))
87    });
88    locations.dedup_by(|a, b| a.uri == b.uri && a.range.start.line == b.range.start.line);
89    locations
90}
91
92/// Phase J — Find implementations via the salsa-memoized workspace aggregate.
93/// Uses the pre-built `subtypes_of[word]` reverse map for O(matches) lookups,
94/// with an additional pass over the FQN's `subtypes_of` entry when the caller
95/// supplied one (covers classes that wrote out the fully-qualified form in
96/// their `extends`/`implements` clause).
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                if extends_match || implements_match {
193                    let range = if let Some(class_name) = c.name {
194                        sv.name_range_in_span(class_name.or_error(), stmt.span)
195                    } else {
196                        // Anonymous class (`new class {}`): point to the `class` keyword.
197                        sv.name_range_in_span("class", stmt.span)
198                    };
199                    out.push(Location {
200                        uri: uri.clone(),
201                        range,
202                    });
203                }
204            }
205            StmtKind::Enum(e) => {
206                let implements_match = e
207                    .implements
208                    .iter()
209                    .any(|iface| name_matches(iface.to_string_repr().as_ref(), word, fqn));
210                if implements_match {
211                    out.push(Location {
212                        uri: uri.clone(),
213                        range: sv.name_range_in_span(e.name.or_error(), stmt.span),
214                    });
215                }
216            }
217            StmtKind::Interface(i) => {
218                let extends_match = i
219                    .extends
220                    .iter()
221                    .any(|base| name_matches(base.to_string_repr().as_ref(), word, fqn));
222                if extends_match {
223                    out.push(Location {
224                        uri: uri.clone(),
225                        range: sv.name_range_in_span(i.name.or_error(), stmt.span),
226                    });
227                }
228            }
229            StmtKind::Expression(expr) => {
230                collect_anon_class_in_expr(expr, word, fqn, sv, stmt.span, uri, out);
231            }
232            StmtKind::Namespace(ns) => {
233                if let NamespaceBody::Braced(inner) = &ns.body {
234                    collect_implementations(&inner.stmts, word, fqn, sv, uri, out);
235                }
236            }
237            _ => {}
238        }
239    }
240}
241
242/// Returns `true` when `name` is a use-import alias in `use_imports` that
243/// resolves to `fqn`. Handles both `Ns\Name` and `\Ns\Name` stored forms.
244pub(crate) fn alias_resolves_to(
245    name: &str,
246    fqn: &str,
247    use_imports: &[(Box<str>, Box<str>)],
248) -> bool {
249    use_imports.iter().any(|(alias, resolved)| {
250        alias.as_ref() == name
251            && (resolved.as_ref() == fqn || resolved.trim_start_matches('\\') == fqn)
252    })
253}
254
255/// Mir-backed variant of [`find_implementations_from_workspace`].
256///
257/// `subtype_urls` is the set of files mir identified as containing subtypes of
258/// the target (from `DocumentStore::class_subtype_urls`). Fixes aliased
259/// `extends` (`use App\Base as X; class C extends X {}`) and FQN-qualified
260/// forms that the raw-name `subtypes_of` map misses. Falls back to
261/// [`find_implementations_from_workspace`] when `subtype_urls` is empty (cold
262/// mir session or class not yet ingested).
263pub fn find_implementations_mir_backed(
264    word: &str,
265    fqn: Option<&str>,
266    wi: &crate::db::workspace_index::WorkspaceIndexData,
267    subtype_urls: &[Url],
268) -> Vec<Location> {
269    if subtype_urls.is_empty() {
270        return find_implementations_from_workspace(word, fqn, wi);
271    }
272    let url_set: HashSet<&Url> = subtype_urls.iter().collect();
273    let mut locations = Vec::new();
274    for (uri, idx) in &wi.files {
275        if !url_set.contains(uri) {
276            continue;
277        }
278        for cls in &idx.classes {
279            let extends_match = cls
280                .parent
281                .as_deref()
282                .map(|p| {
283                    name_matches(p, word, fqn)
284                        || fqn.is_some_and(|f| alias_resolves_to(p, f, &idx.use_imports))
285                })
286                .unwrap_or(false);
287            let implements_match = cls.implements.iter().any(|iface| {
288                name_matches(iface.as_ref(), word, fqn)
289                    || fqn.is_some_and(|f| alias_resolves_to(iface.as_ref(), f, &idx.use_imports))
290            });
291            if extends_match || implements_match {
292                let pos = tower_lsp::lsp_types::Position {
293                    line: cls.start_line,
294                    character: 0,
295                };
296                locations.push(Location {
297                    uri: uri.clone(),
298                    range: tower_lsp::lsp_types::Range {
299                        start: pos,
300                        end: pos,
301                    },
302                });
303            }
304        }
305    }
306    locations.sort_by(|a, b| {
307        a.uri
308            .as_str()
309            .cmp(b.uri.as_str())
310            .then(a.range.start.line.cmp(&b.range.start.line))
311    });
312    locations.dedup_by(|a, b| a.uri == b.uri && a.range.start.line == b.range.start.line);
313    locations
314}
315
316/// Mir-backed variant of [`find_method_implementations_from_workspace`].
317///
318/// `subtype_urls` is the set of files mir identified as containing subtypes of
319/// the declaring class. Scopes the search to those files instead of walking
320/// the raw-name `subtypes_of` map. Falls back to
321/// [`find_method_implementations_from_workspace`] when `subtype_urls` is empty.
322pub fn find_method_implementations_mir_backed(
323    method_name: &str,
324    declaring_class: &str,
325    wi: &crate::db::workspace_index::WorkspaceIndexData,
326    subtype_urls: &[Url],
327) -> Vec<tower_lsp::lsp_types::Location> {
328    if subtype_urls.is_empty() {
329        return find_method_implementations_from_workspace(method_name, declaring_class, wi);
330    }
331    let url_set: HashSet<&Url> = subtype_urls.iter().collect();
332    let mut locations = Vec::new();
333    for (uri, idx) in &wi.files {
334        if !url_set.contains(uri) {
335            continue;
336        }
337        for cls in &idx.classes {
338            if let Some(method) = cls
339                .methods
340                .iter()
341                .find(|m| m.name.as_ref() == method_name && !m.is_abstract)
342            {
343                locations.push(tower_lsp::lsp_types::Location {
344                    uri: uri.clone(),
345                    range: crate::text::zero_width_range(method.start_line),
346                });
347            }
348        }
349    }
350    locations.sort_by(|a, b| {
351        a.uri
352            .as_str()
353            .cmp(b.uri.as_str())
354            .then(a.range.start.line.cmp(&b.range.start.line))
355    });
356    locations.dedup_by(|a, b| a.uri == b.uri && a.range.start.line == b.range.start.line);
357    locations
358}
359
360/// Recurse into an expression to find `new class {}` anonymous class declarations
361/// that implement or extend the target interface/class.
362fn collect_anon_class_in_expr(
363    expr: &php_ast::Expr<'_, '_>,
364    word: &str,
365    fqn: Option<&str>,
366    sv: SourceView<'_>,
367    stmt_span: php_ast::Span,
368    uri: &Url,
369    out: &mut Vec<Location>,
370) {
371    match &expr.kind {
372        ExprKind::AnonymousClass(c) => {
373            let extends_match = c
374                .extends
375                .as_ref()
376                .map(|e| name_matches(e.to_string_repr().as_ref(), word, fqn))
377                .unwrap_or(false);
378            let implements_match = c
379                .implements
380                .iter()
381                .any(|iface| name_matches(iface.to_string_repr().as_ref(), word, fqn));
382            if extends_match || implements_match {
383                // Emit the `class` keyword within the expression span as the location.
384                out.push(Location {
385                    uri: uri.clone(),
386                    range: sv.name_range_in_span("class", stmt_span),
387                });
388            }
389        }
390        ExprKind::New(n) => {
391            collect_anon_class_in_expr(n.class, word, fqn, sv, stmt_span, uri, out);
392        }
393        ExprKind::Assign(a) => {
394            collect_anon_class_in_expr(a.value, word, fqn, sv, stmt_span, uri, out);
395        }
396        _ => {}
397    }
398}