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.
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 path = self.psr4.load().resolve(fqn)?;
155
156        let file_uri = Url::from_file_path(&path).ok()?;
157
158        // Index on-demand if the file was not picked up by the workspace scan.
159        // Use `get_doc_salsa_any` (ignores open-file gating): after `ingest()`
160        // the file is mirrored but background-only, and the call site needs
161        // the AST regardless of whether the editor has the file open.
162        if self.docs.get_doc_salsa(&file_uri).is_none() {
163            let text = tokio::fs::read_to_string(&path).await.ok()?;
164            self.ingest_if_not_open(file_uri.clone(), &text);
165        }
166
167        let doc = self.docs.get_doc_salsa(&file_uri)?;
168
169        // Classes are declared by their short (unqualified) name, e.g. `class Foo`
170        // not `class App\Services\Foo`.
171        let short_name = fqn.split('\\').next_back()?;
172        let range = find_declaration_range(doc.source(), &doc, short_name)?;
173
174        Some(Location {
175            uri: file_uri,
176            range,
177        })
178    }
179
180    /// Walk the PSR-4 class hierarchy starting from `class_fqn` to find the
181    /// definition of `method_name`. Follows the PHP method-resolution order
182    /// (traits → parent) through vendor files that were excluded from the
183    /// eager workspace scan. Files are lazily ingested into the document store
184    /// on first visit; their `FileIndex` is cached in `vendor_index_cache` so
185    /// repeated navigation to the same vendor class is cheap.
186    pub(super) async fn psr4_method_goto(
187        &self,
188        class_fqn: &str,
189        method_name: &str,
190    ) -> Option<Location> {
191        use crate::index::file_index::FileIndex;
192        use crate::navigation::definition::{find_declaration_range, find_method_range_in_class};
193        use crate::text::zero_width_range;
194        use std::collections::{HashSet, VecDeque};
195
196        let mut queue: VecDeque<String> = VecDeque::from([class_fqn.to_owned()]);
197        let mut visited: HashSet<String> = HashSet::new();
198
199        while let Some(fqn) = queue.pop_front() {
200            if !visited.insert(fqn.clone()) {
201                continue;
202            }
203
204            let path = match self.psr4.load().resolve(&fqn) {
205                Some(p) => p,
206                None => continue,
207            };
208            let uri = match Url::from_file_path(&path) {
209                Ok(u) => u,
210                Err(_) => continue,
211            };
212
213            // Lazy-load into the workspace so get_doc_salsa works below.
214            if self.docs.get_doc_salsa(&uri).is_none() {
215                let text = match tokio::fs::read_to_string(&path).await {
216                    Ok(t) => t,
217                    Err(_) => continue,
218                };
219                self.ingest_if_not_open(uri.clone(), &text);
220            }
221
222            let doc = match self.docs.get_doc_salsa(&uri) {
223                Some(d) => d,
224                None => continue,
225            };
226
227            // Use a cached FileIndex when available to avoid re-extracting.
228            let index = self.docs.get_vendor_index(&uri).unwrap_or_else(|| {
229                let idx = Arc::new(FileIndex::extract(&doc));
230                self.docs.cache_vendor_index(uri.clone(), Arc::clone(&idx));
231                idx
232            });
233
234            let short = crate::text::fqn_short_name(&fqn);
235
236            for cls in &index.classes {
237                if cls.name.as_ref() != short {
238                    continue;
239                }
240
241                for m in &cls.methods {
242                    if m.name.as_ref() == method_name {
243                        let range = find_method_range_in_class(&doc, short, method_name)
244                            .or_else(|| find_declaration_range(doc.source(), &doc, method_name))
245                            .unwrap_or_else(|| zero_width_range(m.start_line));
246                        return Some(Location { uri, range });
247                    }
248                }
249                for dm in &cls.doc_methods {
250                    if dm.name.as_ref() == method_name {
251                        return Some(Location {
252                            uri,
253                            range: zero_width_range(dm.start_line),
254                        });
255                    }
256                }
257
258                // Queue parent chain in PHP MRO order: traits → mixins → parent.
259                for trt in &cls.traits {
260                    queue.push_back(resolve_name_to_fqn(trt.as_ref(), &index));
261                }
262                for mx in &cls.mixins {
263                    queue.push_back(resolve_name_to_fqn(mx.as_ref(), &index));
264                }
265                if let Some(parent) = &cls.parent {
266                    queue.push_back(resolve_name_to_fqn(parent.as_ref(), &index));
267                }
268            }
269        }
270        None
271    }
272
273    /// Pre-load via PSR-4 any direct supertypes of `item_name` that are not yet
274    /// present in the workspace index, so the next call to `workspace_index_async`
275    /// will include them. Only one level is loaded (direct parents / interfaces);
276    /// the type-hierarchy feature only ever requests one level at a time.
277    /// Returns `true` when at least one new file was ingested.
278    pub(super) async fn ensure_direct_supertypes_loaded(
279        &self,
280        item_name: &str,
281        wi: &crate::db::workspace_index::WorkspaceIndexData,
282    ) -> bool {
283        let refs = match wi.classes_by_name.get(item_name) {
284            Some(r) => r.clone(),
285            None => return false,
286        };
287
288        let mut ingested = false;
289        for r in &refs {
290            let Some((_, cls)) = wi.at(*r) else {
291                continue;
292            };
293            let file_idx = wi.files.get(r.file as usize).map(|(_, idx)| idx.as_ref());
294
295            let mut super_names: Vec<String> = Vec::new();
296            if let Some(p) = &cls.parent {
297                super_names.push(p.as_ref().to_owned());
298            }
299            for iface in &cls.implements {
300                super_names.push(iface.as_ref().to_owned());
301            }
302
303            for name in super_names {
304                let short = crate::text::fqn_short_name(&name);
305                if wi.classes_by_name.contains_key(short) {
306                    continue;
307                }
308                // Resolve short name to FQN via the implementing file's use_imports.
309                let fqn = if let Some(idx) = file_idx {
310                    resolve_name_to_fqn(&name, idx)
311                } else {
312                    name.clone()
313                };
314                let path = match self.psr4.load().resolve(&fqn) {
315                    Some(p) => p,
316                    None => continue,
317                };
318                let uri = match Url::from_file_path(&path) {
319                    Ok(u) => u,
320                    Err(_) => continue,
321                };
322                if self.docs.get_doc_salsa(&uri).is_some() {
323                    continue;
324                }
325                let text = match tokio::fs::read_to_string(&path).await {
326                    Ok(t) => t,
327                    Err(_) => continue,
328                };
329                self.ingest_if_not_open(uri, &text);
330                ingested = true;
331            }
332        }
333        ingested
334    }
335
336    /// Request the client to apply a workspace edit.
337    /// Returns true if the edit was successfully applied, false otherwise.
338    pub async fn apply_workspace_edit(&self, edit: WorkspaceEdit) -> bool {
339        self.client
340            .apply_edit(edit)
341            .await
342            .ok()
343            .map(|result| result.applied)
344            .unwrap_or(false)
345    }
346}
347
348/// Resolve a potentially-short class `name` to a fully-qualified name by
349/// looking it up in `index.use_imports` and `index.namespace`. Used when
350/// walking a vendor class hierarchy where parent names are stored as written
351/// in the source (e.g. `"AbstractController"` rather than the full FQN).
352fn resolve_name_to_fqn(name: &str, index: &crate::index::file_index::FileIndex) -> String {
353    // Already qualified — strip leading backslash and return.
354    if name.contains('\\') {
355        return name.trim_start_matches('\\').to_owned();
356    }
357    // Resolve through `use` imports (e.g. `use Symfony\...\AbstractController`).
358    for (alias, fqn) in &index.use_imports {
359        if alias.as_ref() == name {
360            return fqn.as_ref().trim_start_matches('\\').to_owned();
361        }
362    }
363    // Apply the current namespace as the last resort.
364    if let Some(ns) = &index.namespace {
365        return format!("{}\\{}", ns.trim_start_matches('\\'), name);
366    }
367    name.to_owned()
368}