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