php_lsp/navigation/implementation.rs
1/// `textDocument/implementation` — name-matching helpers shared by the
2/// type-hierarchy walkers. Implementation lookups themselves are answered
3/// from mir's subtype edge index (`DocumentStore::indexed_subtype_classes`).
4/// Returns `true` when the name written in an `extends`/`implements` clause
5/// (given as its `to_string_repr()` string) refers to the symbol we are
6/// searching for.
7///
8/// Three forms are accepted:
9/// - Short-name match: `repr == word`
10/// Covers the common case where both files use the same unqualified name.
11/// - FQN match: `repr` (with any leading `\` stripped) `== fqn`
12/// Covers files that write the fully-qualified form (`\App\Animal` or
13/// `App\Animal`) while the cursor file imports the class with a `use`
14/// statement and the cursor sits on the short alias.
15/// - Global-namespace backslash match: `repr.trim_start_matches('\\') == word`
16/// when `fqn` is `None` and `word` has no namespace separator.
17/// Covers the case where a class writes `extends \Animal` (explicit global-
18/// namespace form) and the cursor sits on a global-namespace `Animal`
19/// interface with no `use` import.
20#[inline]
21pub(crate) fn name_matches(repr: &str, word: &str, fqn: Option<&str>) -> bool {
22 repr == word
23 || fqn.is_some_and(|f| repr.trim_start_matches('\\') == f)
24 || (fqn.is_none() && !word.contains('\\') && repr.trim_start_matches('\\') == word)
25}
26
27/// Returns `true` when `name` is a use-import alias in `use_imports` that
28/// resolves to `fqn`. Handles both `Ns\Name` and `\Ns\Name` stored forms.
29pub(crate) fn alias_resolves_to(
30 name: &str,
31 fqn: &str,
32 use_imports: &[(Box<str>, Box<str>)],
33) -> bool {
34 use_imports.iter().any(|(alias, resolved)| {
35 alias.as_ref() == name
36 && (resolved.as_ref() == fqn || resolved.trim_start_matches('\\') == fqn)
37 })
38}
39
40/// Returns `true` when `written` — a name from an `extends`/`implements`/`use`
41/// clause in `idx`'s file — refers to `target_fqn`.
42///
43/// Unlike [`name_matches`], this does not treat a bare short-name match as
44/// automatically correct: when `written` has no explicit FQN form, it is
45/// resolved through `idx.use_imports` first, and an entry found there is
46/// authoritative even if its resolved FQN differs from `target_fqn` (an
47/// explicit import always shadows a same-named symbol elsewhere). Only when
48/// no import shadows `written` does it fall back to implicit same-namespace
49/// resolution, mirroring how PHP resolves an unqualified class name with no
50/// matching `use` statement to `<current-namespace>\<written>`.
51///
52/// This is the disambiguation the raw `subtypes_of` short-name prefilter
53/// cannot do on its own: many unrelated classes across a large workspace can
54/// share both a short name (e.g. `Factory`) and a same-named `use` alias
55/// (e.g. `FactoryContract`) while resolving to entirely different FQNs.
56pub(crate) fn resolves_to_fqn(
57 written: &str,
58 target_fqn: &str,
59 idx: &crate::index::file_index::FileIndex,
60) -> bool {
61 if written.contains('\\') {
62 return written.trim_start_matches('\\') == target_fqn;
63 }
64 if let Some((_, resolved)) = idx
65 .use_imports
66 .iter()
67 .find(|(alias, _)| alias.as_ref() == written)
68 {
69 return resolved.as_ref() == target_fqn || resolved.trim_start_matches('\\') == target_fqn;
70 }
71 match idx.namespace.as_deref() {
72 Some(ns) => format!("{ns}\\{written}") == target_fqn,
73 None => written == target_fqn,
74 }
75}