Skip to main content

php_lsp/navigation/
references.rs

1use std::collections::{HashMap, HashSet};
2use std::ops::ControlFlow;
3use std::sync::Arc;
4
5use php_ast::visitor::{Visitor, walk_stmt};
6use php_ast::{Stmt, StmtKind, UseKind};
7use tower_lsp_server::ls_types::{Location, Position, Range, Uri};
8
9use super::walk::all_class_ref_names_in_stmts;
10use crate::document::ast::ParsedDoc;
11
12/// What kind of symbol the cursor is on.  Used to dispatch to the
13/// appropriate semantic walker so that, e.g., searching for `get` as a
14/// *method* doesn't return free-function calls named `get`.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum SymbolKind {
17    /// A free (top-level) function.
18    Function,
19    /// An instance or static method (`->name`, `?->name`, `::name`).
20    Method,
21    /// A class, interface, trait, or enum name used as a type.
22    Class,
23    /// A class / trait property (`->name`, `?->name`, promoted or declared).
24    Property,
25    /// A class, interface, enum, or trait constant (`Class::CONST`, `self::CONST`).
26    Constant,
27}
28
29/// Convert a session reference tuple `(file_uri, line, col_start, col_end)` —
30/// as produced by `DocumentStore::indexed_references` — into an LSP
31/// `Location`. Returns `None` when the file URI fails to parse.
32pub(crate) fn session_tuple_to_location(
33    (file, line, col_start, col_end): (Arc<str>, u32, u32, u32),
34) -> Option<Location> {
35    let uri = file.parse::<Uri>().ok()?;
36    Some(Location {
37        uri,
38        range: Range {
39            start: Position {
40                line,
41                character: col_start,
42            },
43            end: Position {
44                line,
45                character: col_end,
46            },
47        },
48    })
49}
50
51/// Dedup key for a reference location: `(uri, start line, start char, end char)`.
52/// Finer than `type_definition`'s `(uri, line)` key — two references on the same
53/// line (e.g. chained calls) are distinct results and must both survive.
54pub(crate) fn ref_location_key(loc: &Location) -> (String, u32, u32, u32) {
55    (
56        loc.uri.to_string(),
57        loc.range.start.line,
58        loc.range.start.character,
59        loc.range.end.character,
60    )
61}
62
63/// De-duplicate reference locations by [`ref_location_key`], preserving
64/// first-seen order.
65pub(crate) fn dedup_ref_locations(locations: &mut Vec<Location>) {
66    let mut seen = HashSet::new();
67    locations.retain(|loc| seen.insert(ref_location_key(loc)));
68}
69
70struct ImportsVisitor {
71    only_kind: Option<UseKind>,
72    out: HashMap<String, String>,
73}
74
75impl<'arena, 'src> Visitor<'arena, 'src> for ImportsVisitor {
76    fn visit_stmt(&mut self, stmt: &Stmt<'arena, 'src>) -> ControlFlow<()> {
77        match &stmt.kind {
78            StmtKind::Use(u) if self.only_kind.is_none_or(|k| u.kind == k) => {
79                for item in u.uses.iter() {
80                    let (short, fqn) = crate::document::ast::use_item_alias_and_fqn(item);
81                    self.out.insert(short, fqn);
82                }
83                ControlFlow::Continue(())
84            }
85            // walk_stmt recurses into NamespaceBody::Braced automatically.
86            StmtKind::Namespace(_) => walk_stmt(self, stmt),
87            _ => ControlFlow::Continue(()),
88        }
89    }
90}
91
92/// Build a local-name → FQN map from a doc's `use` statements.  Mirrors
93/// `Backend::file_imports` but self-contained so the reference walker can
94/// run without a persistent codebase. Includes all use kinds (class, function,
95/// const) — callers that only want class imports should use `collect_class_imports`.
96pub(crate) fn collect_file_imports(doc: &ParsedDoc) -> HashMap<String, String> {
97    collect_imports_filtered(doc, None)
98}
99
100/// Like `collect_file_imports` but restricted to `use ClassName` statements
101/// (`UseKind::Normal`). Use this wherever the import map is fed into class
102/// resolution — mixing in `use function` / `use const` entries causes the
103/// resolver to map a function/const short name to the wrong FQN when the same
104/// short name appears as a type hint or class reference.
105///
106/// TODO: upstream fix — have mir's FileAnalyzer auto-load via its ClassResolver
107/// so lsp no longer needs to pre-collect class dependencies manually.
108pub(crate) fn collect_class_imports(doc: &ParsedDoc) -> HashMap<String, String> {
109    collect_imports_filtered(doc, Some(UseKind::Normal))
110}
111
112fn collect_imports_filtered(
113    doc: &ParsedDoc,
114    only_kind: Option<UseKind>,
115) -> HashMap<String, String> {
116    let mut v = ImportsVisitor {
117        only_kind,
118        out: HashMap::new(),
119    };
120    for stmt in doc.program().stmts.iter() {
121        let _ = v.visit_stmt(stmt);
122    }
123    v.out
124}
125
126/// Collect every class-typed reference in `doc` (extends, implements, new,
127/// instanceof, type hints, static calls, catch types), resolved to an FQN via
128/// the current namespace and `use` imports. Used to lazy-load same-namespace
129/// dependencies that have no explicit `use` statement (and so are missed by
130/// `collect_file_imports`) before semantic analysis runs.
131///
132/// Returns de-duplicated FQNs with any leading `\` stripped.
133pub(crate) fn collect_referenced_class_fqns(doc: &ParsedDoc) -> Vec<String> {
134    let imports = collect_class_imports(doc);
135    let names = all_class_ref_names_in_stmts(&doc.program().stmts);
136    let locals = collect_local_type_decl_fqns(doc);
137    let mut out: Vec<String> = names
138        .into_iter()
139        .map(|name| {
140            // A leading `\` marks an already-fully-qualified reference like
141            // `new \App\Model\Entity()` — strip the slash and use as-is.
142            // `resolve_fqn` would otherwise prepend the current namespace.
143            if let Some(stripped) = name.strip_prefix('\\') {
144                return stripped.to_string();
145            }
146            let fqn = crate::navigation::moniker::resolve_fqn(doc, &name, &imports);
147            fqn.trim_start_matches('\\').to_string()
148        })
149        // Skip references that resolve to a type declared in this very file —
150        // mir already has them via `session.ingest_file`, and asking it to
151        // lazy-load them can recurse back through analysis.
152        .filter(|fqn| !locals.contains(fqn))
153        .collect();
154    out.sort_unstable();
155    out.dedup();
156    out
157}
158
159fn collect_local_type_decl_fqns(doc: &ParsedDoc) -> HashSet<String> {
160    use php_ast::NamespaceBody;
161    let mut out = HashSet::new();
162    fn name_of(kind: &StmtKind<'_, '_>) -> Option<String> {
163        match kind {
164            StmtKind::Class(c) => c.name.as_ref().map(|n| n.to_string()),
165            StmtKind::Interface(i) => Some(i.name.to_string()),
166            StmtKind::Trait(t) => Some(t.name.to_string()),
167            StmtKind::Enum(e) => Some(e.name.to_string()),
168            _ => None,
169        }
170    }
171    let mut current_ns: Option<String> = None;
172    for stmt in doc.program().stmts.iter() {
173        match &stmt.kind {
174            StmtKind::Namespace(ns) => {
175                let ns_name = ns.name.as_ref().map(|n| n.to_string_repr().to_string());
176                match &ns.body {
177                    NamespaceBody::Braced(inner) => {
178                        let prefix = ns_name
179                            .as_deref()
180                            .map(|n| format!("{n}\\"))
181                            .unwrap_or_default();
182                        for s in inner.stmts.iter() {
183                            if let Some(n) = name_of(&s.kind) {
184                                out.insert(format!("{prefix}{n}"));
185                            }
186                        }
187                    }
188                    NamespaceBody::Simple => {
189                        current_ns = ns_name;
190                    }
191                }
192            }
193            k => {
194                if let Some(n) = name_of(k) {
195                    let fqn = match &current_ns {
196                        Some(ns) => format!("{ns}\\{n}"),
197                        None => n,
198                    };
199                    out.insert(fqn);
200                }
201            }
202        }
203    }
204    out
205}
206
207/// Build the typed `mir_analyzer::Name` for a declaration-site cursor from
208/// the classified `(word, kind)` plus resolved owner/target FQNs. Usage-site
209/// cursors don't come through here — mir's targeted `AnalysisSession::name_at`
210/// path already carries the resolved symbol identity.
211///
212/// `target_fqn` is the symbol's own FQN for Function/Class, the owning FQCN
213/// for Method/Property, and for Constant either the owning FQCN (class
214/// constant, `class_constant = true`) or the constant's FQN (global).
215/// A missing FQN falls back to `word` for Function/Class/Constant (global
216/// namespace); Method/Property require the owner and return `None` without it.
217pub fn build_mir_symbol(
218    word: &str,
219    kind: Option<SymbolKind>,
220    target_fqn: Option<&str>,
221    class_constant: bool,
222) -> Option<mir_analyzer::Name> {
223    let norm = |s: &str| -> Arc<str> { Arc::from(s.trim_start_matches('\\')) };
224    match kind {
225        Some(SymbolKind::Function) => Some(mir_analyzer::Name::Function(norm(
226            target_fqn.unwrap_or(word),
227        ))),
228        Some(SymbolKind::Class) => {
229            Some(mir_analyzer::Name::Class(norm(target_fqn.unwrap_or(word))))
230        }
231        // An unresolvable owner (call/access on an untyped receiver, outside
232        // any class) becomes an empty class: mir answers those from its
233        // name-keyed fallback postings.
234        Some(SymbolKind::Method) => Some(mir_analyzer::Name::Method {
235            class: norm(target_fqn.unwrap_or("")),
236            // PHP method dispatch is case-insensitive; normalize here.
237            name: Arc::from(word.to_ascii_lowercase()),
238        }),
239        Some(SymbolKind::Property) => Some(mir_analyzer::Name::Property {
240            class: norm(target_fqn.unwrap_or("")),
241            name: Arc::from(word),
242        }),
243        Some(SymbolKind::Constant) => Some(if class_constant {
244            mir_analyzer::Name::ClassConstant {
245                class: norm(target_fqn?),
246                name: Arc::from(word),
247            }
248        } else {
249            mir_analyzer::Name::GlobalConstant(norm(target_fqn.unwrap_or(word)))
250        }),
251        None => None,
252    }
253}