Skip to main content

php_lsp/backend/helpers/
mod.rs

1//! Backend support helpers, grouped by concern:
2//! - [`position`] — character/offset math and the symbol-kind heuristic,
3//! - [`cursor_decl`] — cursor-on-declaration detection,
4//! - [`phpunit`] — the `vendor/bin/phpunit` runner.
5//!
6//! This module file keeps the LSP file-operation registration, the deferred
7//! code-action machinery, and the non-blocking `Backend` wrappers that don't
8//! belong to any of the above.
9
10use std::sync::Arc;
11
12use tower_lsp::lsp_types::*;
13
14use crate::document::ast::ParsedDoc;
15use crate::navigation::definition::find_declaration_range;
16
17use crate::actions::generate_action::{
18    generate_constructor_actions, generate_getters_setters_actions,
19};
20use crate::actions::implement_action::implement_missing_actions;
21use crate::actions::phpdoc_action::phpdoc_actions;
22use crate::actions::promote_action::promote_constructor_actions;
23use crate::actions::type_action::add_return_type_actions;
24
25use super::Backend;
26
27mod cursor_decl;
28mod phpunit;
29mod position;
30
31pub(super) use cursor_decl::*;
32pub(super) use phpunit::*;
33pub(super) use position::*;
34
35pub(super) fn php_file_op() -> FileOperationRegistrationOptions {
36    FileOperationRegistrationOptions {
37        filters: vec![FileOperationFilter {
38            scheme: Some("file".to_string()),
39            pattern: FileOperationPattern {
40                glob: "**/*.php".to_string(),
41                matches: Some(FileOperationPatternKind::File),
42                options: None,
43            },
44        }],
45    }
46}
47
48/// Strip the `edit` from each `CodeAction` and attach a `data` payload so the
49/// client can request the edit lazily via `codeAction/resolve`.
50pub(super) fn defer_actions(
51    actions: Vec<CodeActionOrCommand>,
52    kind_tag: &str,
53    uri: &Url,
54    range: Range,
55) -> Vec<CodeActionOrCommand> {
56    actions
57        .into_iter()
58        .map(|a| match a {
59            CodeActionOrCommand::CodeAction(mut ca) => {
60                ca.edit = None;
61                ca.data = Some(serde_json::json!({
62                    "php_lsp_resolve": kind_tag,
63                    "uri": uri.to_string(),
64                    "range": range,
65                }));
66                CodeActionOrCommand::CodeAction(ca)
67            }
68            other => other,
69        })
70        .collect()
71}
72
73/// Tags for deferred code actions (resolved lazily via `codeAction/resolve`).
74/// Iteration order controls the order items appear in the client menu.
75pub(super) const DEFERRED_ACTION_TAGS: &[&str] = &[
76    "phpdoc",
77    "implement",
78    "constructor",
79    "getters_setters",
80    "return_type",
81    "promote",
82];
83
84impl Backend {
85    /// Run [`crate::document::document_store::DocumentStore::cached_analysis`] without
86    /// blocking the async executor. The warm path (cache entry current for the
87    /// file's text) resolves synchronously; the cold path — mir Pass 1 + Pass 2,
88    /// which can take hundreds of ms on large files and is hit after every
89    /// keystroke because edits clear the analysis cache — runs on the blocking
90    /// pool so it doesn't stall other in-flight requests.
91    pub(super) async fn cached_analysis_async(
92        &self,
93        uri: &Url,
94    ) -> Option<Arc<mir_analyzer::FileAnalysis>> {
95        if let Some(hit) = self.docs.cached_analysis_if_fresh(uri) {
96            return Some(hit);
97        }
98        let docs = Arc::clone(&self.docs);
99        let uri = uri.clone();
100        tokio::task::spawn_blocking(move || docs.cached_analysis(&uri))
101            .await
102            .unwrap_or(None)
103    }
104
105    /// Fetch the salsa-memoized workspace aggregate without blocking the async
106    /// executor. A warm memo returns quickly, but the cold rebuild after any
107    /// file change walks every `FileIndex` in the workspace — run it on the
108    /// blocking pool.
109    pub(super) async fn workspace_index_async(
110        &self,
111    ) -> Arc<crate::db::workspace_index::WorkspaceIndexData> {
112        let docs = Arc::clone(&self.docs);
113        match tokio::task::spawn_blocking(move || docs.get_workspace_index_salsa()).await {
114            Ok(wi) => wi,
115            // JoinError (panicked/cancelled blocking task): retry inline so a
116            // panic surfaces through the caller's panic guard.
117            Err(_) => self.docs.get_workspace_index_salsa(),
118        }
119    }
120
121    /// Tag → generator mapping for deferred code actions.
122    pub(super) fn generate_deferred_actions(
123        &self,
124        tag: &str,
125        source: &str,
126        doc: &Arc<ParsedDoc>,
127        range: Range,
128        uri: &Url,
129    ) -> Vec<CodeActionOrCommand> {
130        match tag {
131            "phpdoc" => phpdoc_actions(uri, doc, source, range),
132            "implement" => {
133                let imports = self.file_imports(uri);
134                implement_missing_actions(
135                    source,
136                    doc,
137                    &self.docs.all_docs_for_scan(),
138                    range,
139                    uri,
140                    &imports,
141                )
142            }
143            "constructor" => generate_constructor_actions(source, doc, range, uri),
144            "getters_setters" => generate_getters_setters_actions(source, doc, range, uri),
145            "return_type" => add_return_type_actions(source, doc, range, uri),
146            "promote" => promote_constructor_actions(source, doc, range, uri),
147            _ => Vec::new(),
148        }
149    }
150
151    /// Try to resolve a fully-qualified name via the PSR-4 map, with PSR-0 fallback.
152    /// Indexes the file on-demand if it is not already in the document store.
153    pub(super) async fn psr4_goto(&self, fqn: &str) -> Option<Location> {
154        let psr4 = self.psr4.load();
155        let path = psr4.resolve(fqn).or_else(|| psr4.psr0_resolve(fqn))?;
156
157        let file_uri = Url::from_file_path(&path).ok()?;
158
159        // Index on-demand if the file was not picked up by the workspace scan.
160        // Use `get_doc_salsa_any` (ignores open-file gating): after `ingest()`
161        // the file is mirrored but background-only, and the call site needs
162        // the AST regardless of whether the editor has the file open.
163        if self.docs.get_doc_salsa(&file_uri).is_none() {
164            let text = tokio::fs::read_to_string(&path).await.ok()?;
165            self.ingest_if_not_open(file_uri.clone(), &text);
166        }
167
168        let doc = self.docs.get_doc_salsa(&file_uri)?;
169
170        // Classes are declared by their short (unqualified) name, e.g. `class Foo`
171        // not `class App\Services\Foo`.
172        let short_name = fqn.split('\\').next_back()?;
173        let range = find_declaration_range(doc.source(), &doc, short_name)?;
174
175        Some(Location {
176            uri: file_uri,
177            range,
178        })
179    }
180
181    /// Walk the PSR-4 class hierarchy starting from `class_fqn` to find the
182    /// definition of `method_name`. Follows the PHP method-resolution order
183    /// (traits → parent) through vendor files that were excluded from the
184    /// eager workspace scan. Files are lazily ingested into the document store
185    /// on first visit; their `FileIndex` is cached in `vendor_index_cache` so
186    /// repeated navigation to the same vendor class is cheap.
187    pub(super) async fn psr4_method_goto(
188        &self,
189        class_fqn: &str,
190        method_name: &str,
191    ) -> Option<Location> {
192        use crate::index::file_index::FileIndex;
193        use crate::navigation::definition::{find_declaration_range, find_method_range_in_class};
194        use crate::text::zero_width_range;
195        use std::collections::{HashSet, VecDeque};
196
197        let mut queue: VecDeque<String> = VecDeque::from([class_fqn.to_owned()]);
198        let mut visited: HashSet<String> = HashSet::new();
199
200        while let Some(fqn) = queue.pop_front() {
201            if !visited.insert(fqn.clone()) {
202                continue;
203            }
204
205            let path = match self.psr4.load().resolve(&fqn) {
206                Some(p) => p,
207                None => continue,
208            };
209            let uri = match Url::from_file_path(&path) {
210                Ok(u) => u,
211                Err(_) => continue,
212            };
213
214            // Lazy-load into the workspace so get_doc_salsa works below.
215            if self.docs.get_doc_salsa(&uri).is_none() {
216                let text = match tokio::fs::read_to_string(&path).await {
217                    Ok(t) => t,
218                    Err(_) => continue,
219                };
220                self.ingest_if_not_open(uri.clone(), &text);
221            }
222
223            let doc = match self.docs.get_doc_salsa(&uri) {
224                Some(d) => d,
225                None => continue,
226            };
227
228            // Use a cached FileIndex when available to avoid re-extracting.
229            let index = self.docs.get_vendor_index(&uri).unwrap_or_else(|| {
230                let idx = Arc::new(FileIndex::extract(&doc));
231                self.docs.cache_vendor_index(uri.clone(), Arc::clone(&idx));
232                idx
233            });
234
235            let short = crate::text::fqn_short_name(&fqn);
236
237            for cls in &index.classes {
238                if cls.name.as_ref() != short {
239                    continue;
240                }
241
242                for m in &cls.methods {
243                    if m.name.as_ref() == method_name {
244                        let range = find_method_range_in_class(&doc, short, method_name)
245                            .or_else(|| find_declaration_range(doc.source(), &doc, method_name))
246                            .unwrap_or_else(|| zero_width_range(m.start_line));
247                        return Some(Location { uri, range });
248                    }
249                }
250                for dm in &cls.doc_methods {
251                    if dm.name.as_ref() == method_name {
252                        return Some(Location {
253                            uri,
254                            range: zero_width_range(dm.start_line),
255                        });
256                    }
257                }
258
259                // Queue parent chain in PHP MRO order: traits → mixins → parent.
260                for trt in &cls.traits {
261                    queue.push_back(resolve_name_to_fqn(trt.as_ref(), &index));
262                }
263                for mx in &cls.mixins {
264                    queue.push_back(resolve_name_to_fqn(mx.as_ref(), &index));
265                }
266                if let Some(parent) = &cls.parent {
267                    queue.push_back(resolve_name_to_fqn(parent.as_ref(), &index));
268                }
269            }
270        }
271        None
272    }
273
274    /// Pre-load via PSR-4 any direct supertypes of `item_name` that are not yet
275    /// present in the workspace index, so the next call to `workspace_index_async`
276    /// will include them. Only one level is loaded (direct parents / interfaces);
277    /// the type-hierarchy feature only ever requests one level at a time.
278    /// Returns `true` when at least one new file was ingested.
279    pub(super) async fn ensure_direct_supertypes_loaded(
280        &self,
281        item_name: &str,
282        wi: &crate::db::workspace_index::WorkspaceIndexData,
283    ) -> bool {
284        let refs = match wi.classes_by_name.get(item_name) {
285            Some(r) => r.clone(),
286            None => return false,
287        };
288
289        let mut ingested = false;
290        for r in &refs {
291            let Some((_, cls)) = wi.at(*r) else {
292                continue;
293            };
294            let file_idx = wi.files.get(r.file as usize).map(|(_, idx)| idx.as_ref());
295
296            let mut super_names: Vec<String> = Vec::new();
297            if let Some(p) = &cls.parent {
298                super_names.push(p.as_ref().to_owned());
299            }
300            for iface in &cls.implements {
301                super_names.push(iface.as_ref().to_owned());
302            }
303
304            for name in super_names {
305                let short = crate::text::fqn_short_name(&name);
306                if wi.classes_by_name.contains_key(short) {
307                    continue;
308                }
309                // Resolve short name to FQN via the implementing file's use_imports.
310                let fqn = if let Some(idx) = file_idx {
311                    resolve_name_to_fqn(&name, idx)
312                } else {
313                    name.clone()
314                };
315                let path = match self.psr4.load().resolve(&fqn) {
316                    Some(p) => p,
317                    None => continue,
318                };
319                let uri = match Url::from_file_path(&path) {
320                    Ok(u) => u,
321                    Err(_) => continue,
322                };
323                if self.docs.get_doc_salsa(&uri).is_some() {
324                    continue;
325                }
326                let text = match tokio::fs::read_to_string(&path).await {
327                    Ok(t) => t,
328                    Err(_) => continue,
329                };
330                self.ingest_if_not_open(uri, &text);
331                ingested = true;
332            }
333        }
334        ingested
335    }
336
337    /// Request the client to apply a workspace edit.
338    /// Returns true if the edit was successfully applied, false otherwise.
339    pub async fn apply_workspace_edit(&self, edit: WorkspaceEdit) -> bool {
340        self.client
341            .apply_edit(edit)
342            .await
343            .ok()
344            .map(|result| result.applied)
345            .unwrap_or(false)
346    }
347}
348
349/// Resolve a potentially-short class `name` to a fully-qualified name by
350/// looking it up in `index.use_imports` and `index.namespace`. Used when
351/// walking a vendor class hierarchy where parent names are stored as written
352/// in the source (e.g. `"AbstractController"` rather than the full FQN).
353fn resolve_name_to_fqn(name: &str, index: &crate::index::file_index::FileIndex) -> String {
354    // Already qualified — strip leading backslash and return.
355    if name.contains('\\') {
356        return name.trim_start_matches('\\').to_owned();
357    }
358    // Resolve through `use` imports (e.g. `use Symfony\...\AbstractController`).
359    for (alias, fqn) in &index.use_imports {
360        if alias.as_ref() == name {
361            return fqn.as_ref().trim_start_matches('\\').to_owned();
362        }
363    }
364    // Apply the current namespace as the last resort.
365    if let Some(ns) = &index.namespace {
366        return format!("{}\\{}", ns.trim_start_matches('\\'), name);
367    }
368    name.to_owned()
369}