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                    let file_idx = wi.files.get(r.file as usize).map(|(_, idx)| idx.as_ref());
108                    // Re-check each candidate: the raw short-name `subtypes_of`
109                    // prefilter is over-inclusive across a large workspace (many
110                    // unrelated classes can share both a short name and a
111                    // same-named use-import alias while resolving to different
112                    // FQNs), so when a target FQN is available every candidate
113                    // must be confirmed against its own `use_imports`/namespace
114                    // via `resolves_to_fqn` rather than trusting the bare name.
115                    let extends_match = cls.parent.as_deref().is_some_and(|p| match fqn {
116                        Some(f) => file_idx.is_some_and(|idx| resolves_to_fqn(p, f, idx)),
117                        None => name_matches(p, word, None),
118                    });
119                    let implements_match = cls.implements.iter().any(|iface| match fqn {
120                        Some(f) => {
121                            file_idx.is_some_and(|idx| resolves_to_fqn(iface.as_ref(), f, idx))
122                        }
123                        None => name_matches(iface.as_ref(), word, None),
124                    });
125                    if extends_match || implements_match {
126                        let pos = tower_lsp::lsp_types::Position {
127                            line: cls.start_line,
128                            character: 0,
129                        };
130                        locations.push(Location {
131                            uri: uri.clone(),
132                            range: tower_lsp::lsp_types::Range {
133                                start: pos,
134                                end: pos,
135                            },
136                        });
137                    }
138                }
139            }
140        }
141    };
142    push_refs(word);
143    if let Some(f) = fqn
144        && f != word
145    {
146        push_refs(f);
147        // Cover `\App\Animal`-style leading-backslash forms.
148        let trimmed = f.trim_start_matches('\\');
149        if trimmed != f {
150            push_refs(trimmed);
151        }
152    }
153    // De-dup: a class may list both the bare name and the FQN of the same
154    // parent (unlikely but cheap to guard against).
155    locations.sort_by(|a, b| {
156        a.uri
157            .as_str()
158            .cmp(b.uri.as_str())
159            .then(a.range.start.line.cmp(&b.range.start.line))
160    });
161    locations.dedup_by(|a, b| a.uri == b.uri && a.range.start.line == b.range.start.line);
162    locations
163}
164
165fn collect_implementations(
166    stmts: &[Stmt<'_, '_>],
167    word: &str,
168    fqn: Option<&str>,
169    sv: SourceView<'_>,
170    uri: &Url,
171    out: &mut Vec<Location>,
172) {
173    for stmt in stmts {
174        match &stmt.kind {
175            StmtKind::Class(c) => {
176                let extends_match = c
177                    .extends
178                    .as_ref()
179                    .map(|e| name_matches(e.to_string_repr().as_ref(), word, fqn))
180                    .unwrap_or(false);
181
182                let implements_match = c
183                    .implements
184                    .iter()
185                    .any(|iface| name_matches(iface.to_string_repr().as_ref(), word, fqn));
186
187                if extends_match || implements_match {
188                    let range = if let Some(class_name) = c.name {
189                        sv.name_range_in_span(class_name.or_error(), stmt.span)
190                    } else {
191                        // Anonymous class (`new class {}`): point to the `class` keyword.
192                        sv.name_range_in_span("class", stmt.span)
193                    };
194                    out.push(Location {
195                        uri: uri.clone(),
196                        range,
197                    });
198                }
199            }
200            StmtKind::Enum(e) => {
201                let implements_match = e
202                    .implements
203                    .iter()
204                    .any(|iface| name_matches(iface.to_string_repr().as_ref(), word, fqn));
205                if implements_match {
206                    out.push(Location {
207                        uri: uri.clone(),
208                        range: sv.name_range_in_span(e.name.or_error(), stmt.span),
209                    });
210                }
211            }
212            StmtKind::Interface(i) => {
213                let extends_match = i
214                    .extends
215                    .iter()
216                    .any(|base| name_matches(base.to_string_repr().as_ref(), word, fqn));
217                if extends_match {
218                    out.push(Location {
219                        uri: uri.clone(),
220                        range: sv.name_range_in_span(i.name.or_error(), stmt.span),
221                    });
222                }
223            }
224            StmtKind::Expression(expr) => {
225                collect_anon_class_in_expr(expr, word, fqn, sv, stmt.span, uri, out);
226            }
227            StmtKind::Namespace(ns) => {
228                if let NamespaceBody::Braced(inner) = &ns.body {
229                    collect_implementations(&inner.stmts, word, fqn, sv, uri, out);
230                }
231            }
232            _ => {}
233        }
234    }
235}
236
237/// Returns `true` when `name` is a use-import alias in `use_imports` that
238/// resolves to `fqn`. Handles both `Ns\Name` and `\Ns\Name` stored forms.
239pub(crate) fn alias_resolves_to(
240    name: &str,
241    fqn: &str,
242    use_imports: &[(Box<str>, Box<str>)],
243) -> bool {
244    use_imports.iter().any(|(alias, resolved)| {
245        alias.as_ref() == name
246            && (resolved.as_ref() == fqn || resolved.trim_start_matches('\\') == fqn)
247    })
248}
249
250/// Returns `true` when `written` — a name from an `extends`/`implements`/`use`
251/// clause in `idx`'s file — refers to `target_fqn`.
252///
253/// Unlike [`name_matches`], this does not treat a bare short-name match as
254/// automatically correct: when `written` has no explicit FQN form, it is
255/// resolved through `idx.use_imports` first, and an entry found there is
256/// authoritative even if its resolved FQN differs from `target_fqn` (an
257/// explicit import always shadows a same-named symbol elsewhere). Only when
258/// no import shadows `written` does it fall back to implicit same-namespace
259/// resolution, mirroring how PHP resolves an unqualified class name with no
260/// matching `use` statement to `<current-namespace>\<written>`.
261///
262/// This is the disambiguation the raw `subtypes_of` short-name prefilter
263/// cannot do on its own: many unrelated classes across a large workspace can
264/// share both a short name (e.g. `Factory`) and a same-named `use` alias
265/// (e.g. `FactoryContract`) while resolving to entirely different FQNs.
266pub(crate) fn resolves_to_fqn(
267    written: &str,
268    target_fqn: &str,
269    idx: &crate::index::file_index::FileIndex,
270) -> bool {
271    if written.contains('\\') {
272        return written.trim_start_matches('\\') == target_fqn;
273    }
274    if let Some((_, resolved)) = idx
275        .use_imports
276        .iter()
277        .find(|(alias, _)| alias.as_ref() == written)
278    {
279        return resolved.as_ref() == target_fqn || resolved.trim_start_matches('\\') == target_fqn;
280    }
281    match idx.namespace.as_deref() {
282        Some(ns) => format!("{ns}\\{written}") == target_fqn,
283        None => written == target_fqn,
284    }
285}
286
287/// Mir-backed variant of [`find_implementations_from_workspace`].
288///
289/// `subtype_urls` is the set of files mir identified as containing subtypes of
290/// the target (from `DocumentStore::class_subtype_urls`). Fixes aliased
291/// `extends` (`use App\Base as X; class C extends X {}`) and FQN-qualified
292/// forms that the raw-name `subtypes_of` map misses. Falls back to
293/// [`find_implementations_from_workspace`] when `subtype_urls` is empty (cold
294/// mir session or class not yet ingested).
295pub fn find_implementations_mir_backed(
296    word: &str,
297    fqn: Option<&str>,
298    wi: &crate::db::workspace_index::WorkspaceIndexData,
299    subtype_urls: &[Url],
300) -> Vec<Location> {
301    if subtype_urls.is_empty() {
302        return find_implementations_from_workspace(word, fqn, wi);
303    }
304    let url_set: HashSet<&Url> = subtype_urls.iter().collect();
305    let mut locations = Vec::new();
306    for (uri, idx) in &wi.files {
307        if !url_set.contains(uri) {
308            continue;
309        }
310        for cls in &idx.classes {
311            let extends_match = cls
312                .parent
313                .as_deref()
314                .map(|p| {
315                    name_matches(p, word, fqn)
316                        || fqn.is_some_and(|f| alias_resolves_to(p, f, &idx.use_imports))
317                })
318                .unwrap_or(false);
319            let implements_match = cls.implements.iter().any(|iface| {
320                name_matches(iface.as_ref(), word, fqn)
321                    || fqn.is_some_and(|f| alias_resolves_to(iface.as_ref(), f, &idx.use_imports))
322            });
323            if extends_match || implements_match {
324                let pos = tower_lsp::lsp_types::Position {
325                    line: cls.start_line,
326                    character: 0,
327                };
328                locations.push(Location {
329                    uri: uri.clone(),
330                    range: tower_lsp::lsp_types::Range {
331                        start: pos,
332                        end: pos,
333                    },
334                });
335            }
336        }
337    }
338    locations.sort_by(|a, b| {
339        a.uri
340            .as_str()
341            .cmp(b.uri.as_str())
342            .then(a.range.start.line.cmp(&b.range.start.line))
343    });
344    locations.dedup_by(|a, b| a.uri == b.uri && a.range.start.line == b.range.start.line);
345    locations
346}
347
348/// Mir-backed variant of [`find_method_implementations_from_workspace`].
349///
350/// `subtype_urls` is the set of files mir identified as containing subtypes of
351/// the declaring class. Scopes the search to those files instead of walking
352/// the raw-name `subtypes_of` map. Falls back to
353/// [`find_method_implementations_from_workspace`] when `subtype_urls` is empty.
354pub fn find_method_implementations_mir_backed(
355    method_name: &str,
356    declaring_class: &str,
357    wi: &crate::db::workspace_index::WorkspaceIndexData,
358    subtype_urls: &[Url],
359) -> Vec<tower_lsp::lsp_types::Location> {
360    if subtype_urls.is_empty() {
361        return find_method_implementations_from_workspace(method_name, declaring_class, wi);
362    }
363    let url_set: HashSet<&Url> = subtype_urls.iter().collect();
364    let mut locations = Vec::new();
365    for (uri, idx) in &wi.files {
366        if !url_set.contains(uri) {
367            continue;
368        }
369        for cls in &idx.classes {
370            if let Some(method) = cls
371                .methods
372                .iter()
373                .find(|m| m.name.as_ref() == method_name && !m.is_abstract)
374            {
375                locations.push(tower_lsp::lsp_types::Location {
376                    uri: uri.clone(),
377                    range: crate::text::zero_width_range(method.start_line),
378                });
379            }
380        }
381    }
382    locations.sort_by(|a, b| {
383        a.uri
384            .as_str()
385            .cmp(b.uri.as_str())
386            .then(a.range.start.line.cmp(&b.range.start.line))
387    });
388    locations.dedup_by(|a, b| a.uri == b.uri && a.range.start.line == b.range.start.line);
389    locations
390}
391
392/// Recurse into an expression to find `new class {}` anonymous class declarations
393/// that implement or extend the target interface/class.
394fn collect_anon_class_in_expr(
395    expr: &php_ast::Expr<'_, '_>,
396    word: &str,
397    fqn: Option<&str>,
398    sv: SourceView<'_>,
399    stmt_span: php_ast::Span,
400    uri: &Url,
401    out: &mut Vec<Location>,
402) {
403    match &expr.kind {
404        ExprKind::AnonymousClass(c) => {
405            let extends_match = c
406                .extends
407                .as_ref()
408                .map(|e| name_matches(e.to_string_repr().as_ref(), word, fqn))
409                .unwrap_or(false);
410            let implements_match = c
411                .implements
412                .iter()
413                .any(|iface| name_matches(iface.to_string_repr().as_ref(), word, fqn));
414            if extends_match || implements_match {
415                // Emit the `class` keyword within the expression span as the location.
416                out.push(Location {
417                    uri: uri.clone(),
418                    range: sv.name_range_in_span("class", stmt_span),
419                });
420            }
421        }
422        ExprKind::New(n) => {
423            collect_anon_class_in_expr(n.class, word, fqn, sv, stmt_span, uri, out);
424        }
425        ExprKind::Assign(a) => {
426            collect_anon_class_in_expr(a.value, word, fqn, sv, stmt_span, uri, out);
427        }
428        _ => {}
429    }
430}