Skip to main content

pytest_language_server/providers/
language_server.rs

1//! `LanguageServer` trait implementation for `Backend`.
2//!
3//! This was extracted from `main.rs` so that both the binary crate and the
4//! library crate compile the impl, making `Backend` usable in integration
5//! tests via `LspService::new`.
6
7use std::sync::Arc;
8
9use tower_lsp_server::jsonrpc::Result;
10use tower_lsp_server::ls_types::request::{GotoImplementationParams, GotoImplementationResponse};
11use tower_lsp_server::ls_types::*;
12use tower_lsp_server::LanguageServer;
13use tracing::{error, info, warn};
14
15use super::Backend;
16use crate::config;
17
18impl LanguageServer for Backend {
19    async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
20        info!("Initialize request received");
21
22        // Scan the workspace for fixtures on initialization
23        // This is done in a background task to avoid blocking the LSP initialization
24        // Try workspace_folders first (preferred), fall back to deprecated root_uri
25        let root_uri = params
26            .workspace_folders
27            .as_ref()
28            .and_then(|folders| folders.first())
29            .map(|folder| folder.uri.clone())
30            .or_else(|| {
31                #[allow(deprecated)]
32                params.root_uri.clone()
33            });
34
35        if let Some(root_uri) = root_uri {
36            if let Some(root_path) = root_uri.to_file_path() {
37                let root_path = root_path.to_path_buf();
38                info!("Starting workspace scan: {:?}", root_path);
39
40                // Store the original workspace root (as client provided it)
41                *self.original_workspace_root.write().await = Some(root_path.clone());
42
43                // Store the canonical workspace root (with symlinks resolved)
44                let canonical_root = root_path
45                    .canonicalize()
46                    .unwrap_or_else(|_| root_path.clone());
47                *self.workspace_root.write().await = Some(canonical_root.clone());
48
49                // Load configuration from pyproject.toml
50                let loaded_config = config::Config::load(&root_path);
51                info!("Loaded config: {:?}", loaded_config);
52                *self.config.write().await = loaded_config;
53
54                // Clone references for the background task
55                let fixture_db = Arc::clone(&self.fixture_db);
56                let client = self.client.clone();
57                let exclude_patterns = self.config.read().await.exclude.clone();
58
59                // Spawn workspace scanning in a background task
60                // This allows the LSP to respond immediately while scanning continues
61                let scan_handle = tokio::spawn(async move {
62                    client
63                        .log_message(
64                            MessageType::INFO,
65                            format!("Scanning workspace: {:?}", root_path),
66                        )
67                        .await;
68
69                    // Run the synchronous scan in a blocking task to avoid blocking the async runtime
70                    let scan_result = tokio::task::spawn_blocking(move || {
71                        fixture_db.scan_workspace_with_excludes(&root_path, &exclude_patterns);
72                    })
73                    .await;
74
75                    match scan_result {
76                        Ok(()) => {
77                            info!("Workspace scan complete");
78                            client
79                                .log_message(MessageType::INFO, "Workspace scan complete")
80                                .await;
81                        }
82                        Err(e) => {
83                            error!("Workspace scan failed: {:?}", e);
84                            client
85                                .log_message(
86                                    MessageType::ERROR,
87                                    format!("Workspace scan failed: {:?}", e),
88                                )
89                                .await;
90                        }
91                    }
92                });
93
94                // Store the handle so we can cancel it on shutdown
95                *self.scan_task.lock().await = Some(scan_handle);
96            }
97        } else {
98            warn!("No root URI provided in initialize - workspace scanning disabled");
99            self.client
100                .log_message(
101                    MessageType::WARNING,
102                    "No workspace root provided - fixture analysis disabled",
103                )
104                .await;
105        }
106
107        info!("Returning initialize result with capabilities");
108        Ok(InitializeResult {
109            server_info: Some(ServerInfo {
110                name: "pytest-language-server".to_string(),
111                version: Some(env!("CARGO_PKG_VERSION").to_string()),
112            }),
113            capabilities: ServerCapabilities {
114                definition_provider: Some(OneOf::Left(true)),
115                hover_provider: Some(HoverProviderCapability::Simple(true)),
116                references_provider: Some(OneOf::Left(true)),
117                text_document_sync: Some(TextDocumentSyncCapability::Kind(
118                    TextDocumentSyncKind::FULL,
119                )),
120                code_action_provider: Some(CodeActionProviderCapability::Options(
121                    CodeActionOptions {
122                        code_action_kinds: Some(vec![
123                            CodeActionKind::QUICKFIX,
124                            CodeActionKind::new("source.pytest-ls"),
125                            CodeActionKind::new("source.fixAll.pytest-ls"),
126                        ]),
127                        work_done_progress_options: WorkDoneProgressOptions {
128                            work_done_progress: None,
129                        },
130                        resolve_provider: None,
131                    },
132                )),
133                completion_provider: Some(CompletionOptions {
134                    resolve_provider: Some(false),
135                    trigger_characters: Some(vec![
136                        "\"".to_string(),
137                        "(".to_string(),
138                        ",".to_string(),
139                    ]),
140                    all_commit_characters: None,
141                    work_done_progress_options: WorkDoneProgressOptions {
142                        work_done_progress: None,
143                    },
144                    completion_item: None,
145                }),
146                document_symbol_provider: Some(OneOf::Left(true)),
147                workspace_symbol_provider: Some(OneOf::Left(true)),
148                code_lens_provider: Some(CodeLensOptions {
149                    resolve_provider: Some(false),
150                }),
151                inlay_hint_provider: Some(OneOf::Left(true)),
152                implementation_provider: Some(ImplementationProviderCapability::Simple(true)),
153                call_hierarchy_provider: Some(CallHierarchyServerCapability::Simple(true)),
154                rename_provider: Some(OneOf::Right(RenameOptions {
155                    prepare_provider: Some(true),
156                    work_done_progress_options: WorkDoneProgressOptions {
157                        work_done_progress: None,
158                    },
159                })),
160                ..Default::default()
161            },
162        })
163    }
164
165    async fn initialized(&self, _: InitializedParams) {
166        info!("Server initialized notification received");
167        self.client
168            .log_message(MessageType::INFO, "pytest-language-server initialized")
169            .await;
170
171        // Register a file watcher for __init__.py create/delete events.
172        // When package markers change, `file_path_to_module_path()` results
173        // (captured in `FixtureDefinition::return_type_imports`) become stale,
174        // so we re-analyze affected fixture files to refresh them.
175        let watch_init_py = Registration {
176            id: "watch-init-py".to_string(),
177            method: "workspace/didChangeWatchedFiles".to_string(),
178            register_options: Some(
179                serde_json::to_value(DidChangeWatchedFilesRegistrationOptions {
180                    watchers: vec![FileSystemWatcher {
181                        glob_pattern: GlobPattern::String("**/__init__.py".to_string()),
182                        kind: Some(WatchKind::Create | WatchKind::Delete),
183                    }],
184                })
185                .unwrap(),
186            ),
187        };
188
189        if let Err(e) = self.client.register_capability(vec![watch_init_py]).await {
190            // Not fatal — file watching is best-effort.  The user can still
191            // manually re-open fixture files to trigger re-analysis.
192            info!(
193                "Failed to register __init__.py file watcher (client may not support it): {}",
194                e
195            );
196        }
197    }
198
199    async fn did_open(&self, params: DidOpenTextDocumentParams) {
200        let uri = params.text_document.uri.clone();
201        info!("did_open: {:?}", uri);
202        if let Some(file_path) = self.uri_to_path(&uri) {
203            // Cache the original URI for this canonical path
204            // This ensures we respond with URIs the client recognizes
205            self.uri_cache.insert(file_path.clone(), uri.clone());
206
207            info!("Analyzing file: {:?}", file_path);
208            self.fixture_db
209                .analyze_file(file_path.clone(), &params.text_document.text);
210
211            // Publish diagnostics for undeclared fixtures
212            self.publish_diagnostics_for_file(&uri, &file_path).await;
213        }
214    }
215
216    async fn did_change(&self, params: DidChangeTextDocumentParams) {
217        let uri = params.text_document.uri.clone();
218        info!("did_change: {:?}", uri);
219        if let Some(file_path) = self.uri_to_path(&uri) {
220            if let Some(change) = params.content_changes.first() {
221                info!("Re-analyzing file: {:?}", file_path);
222                self.fixture_db
223                    .analyze_file(file_path.clone(), &change.text);
224
225                // Publish diagnostics for undeclared fixtures
226                self.publish_diagnostics_for_file(&uri, &file_path).await;
227
228                // Request inlay hint refresh so editors update hints after edits
229                // (e.g., when user adds/removes type annotations)
230                if let Err(e) = self.client.inlay_hint_refresh().await {
231                    // Not all clients support this, so just log and continue
232                    info!(
233                        "Inlay hint refresh request failed (client may not support it): {}",
234                        e
235                    );
236                }
237            }
238        }
239    }
240
241    async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
242        // Re-analyze fixture files whose `return_type_imports` may have become
243        // stale because an `__init__.py` was created or deleted, changing the
244        // result of `file_path_to_module_path()`.
245        for event in &params.changes {
246            if event.typ != FileChangeType::CREATED && event.typ != FileChangeType::DELETED {
247                continue;
248            }
249
250            let Some(init_path) = self.uri_to_path(&event.uri) else {
251                continue;
252            };
253
254            // The __init__.py change affects the directory it lives in and
255            // every directory below it.  Any fixture file at or under that
256            // directory may produce a different module path now.
257            let affected_dir = match init_path.parent() {
258                Some(dir) => dir.to_path_buf(),
259                None => continue,
260            };
261
262            let kind = if event.typ == FileChangeType::CREATED {
263                "created"
264            } else {
265                "deleted"
266            };
267            info!(
268                "__init__.py {} in {:?} — re-analyzing affected fixture files",
269                kind, affected_dir
270            );
271
272            // Collect fixture files that live at or below the affected directory.
273            let files_to_reanalyze: Vec<std::path::PathBuf> = self
274                .fixture_db
275                .file_definitions
276                .iter()
277                .filter(|entry| entry.key().starts_with(&affected_dir))
278                .map(|entry| entry.key().clone())
279                .collect();
280
281            for file_path in files_to_reanalyze {
282                if let Some(content) = self.fixture_db.get_file_content(&file_path) {
283                    info!("Re-analyzing {:?} after __init__.py change", file_path);
284                    self.fixture_db.analyze_file(file_path.clone(), &content);
285
286                    // Re-publish diagnostics for the file if we have a cached URI.
287                    if let Some(uri) = self.uri_cache.get(&file_path) {
288                        self.publish_diagnostics_for_file(&uri, &file_path).await;
289                    }
290                }
291            }
292        }
293
294        // Refresh inlay hints in case return types changed.
295        if !params.changes.is_empty() {
296            if let Err(e) = self.client.inlay_hint_refresh().await {
297                info!(
298                    "Inlay hint refresh after __init__.py change failed (client may not support it): {}",
299                    e
300                );
301            }
302        }
303    }
304
305    async fn did_close(&self, params: DidCloseTextDocumentParams) {
306        let uri = params.text_document.uri;
307        info!("did_close: {:?}", uri);
308        if let Some(file_path) = self.uri_to_path(&uri) {
309            // Clean up cached data for this file to prevent unbounded memory growth
310            self.fixture_db.cleanup_file_cache(&file_path);
311            // Clean up URI cache entry
312            self.uri_cache.remove(&file_path);
313        }
314    }
315
316    async fn goto_definition(
317        &self,
318        params: GotoDefinitionParams,
319    ) -> Result<Option<GotoDefinitionResponse>> {
320        self.handle_goto_definition(params).await
321    }
322
323    async fn goto_implementation(
324        &self,
325        params: GotoImplementationParams,
326    ) -> Result<Option<GotoImplementationResponse>> {
327        self.handle_goto_implementation(params).await
328    }
329
330    async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
331        self.handle_hover(params).await
332    }
333
334    async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
335        self.handle_references(params).await
336    }
337
338    async fn prepare_rename(
339        &self,
340        params: TextDocumentPositionParams,
341    ) -> Result<Option<PrepareRenameResponse>> {
342        self.handle_prepare_rename(params).await
343    }
344
345    async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
346        self.handle_rename(params).await
347    }
348
349    async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
350        self.handle_completion(params).await
351    }
352
353    async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
354        self.handle_code_action(params).await
355    }
356
357    async fn document_symbol(
358        &self,
359        params: DocumentSymbolParams,
360    ) -> Result<Option<DocumentSymbolResponse>> {
361        self.handle_document_symbol(params).await
362    }
363
364    async fn symbol(
365        &self,
366        params: WorkspaceSymbolParams,
367    ) -> Result<Option<WorkspaceSymbolResponse>> {
368        let result = self.handle_workspace_symbol(params).await?;
369        Ok(result.map(WorkspaceSymbolResponse::Flat))
370    }
371
372    async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
373        self.handle_code_lens(params).await
374    }
375
376    async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
377        self.handle_inlay_hint(params).await
378    }
379
380    async fn prepare_call_hierarchy(
381        &self,
382        params: CallHierarchyPrepareParams,
383    ) -> Result<Option<Vec<CallHierarchyItem>>> {
384        self.handle_prepare_call_hierarchy(params).await
385    }
386
387    async fn incoming_calls(
388        &self,
389        params: CallHierarchyIncomingCallsParams,
390    ) -> Result<Option<Vec<CallHierarchyIncomingCall>>> {
391        self.handle_incoming_calls(params).await
392    }
393
394    async fn outgoing_calls(
395        &self,
396        params: CallHierarchyOutgoingCallsParams,
397    ) -> Result<Option<Vec<CallHierarchyOutgoingCall>>> {
398        self.handle_outgoing_calls(params).await
399    }
400
401    async fn shutdown(&self) -> Result<()> {
402        info!("Shutdown request received");
403
404        // Cancel the background scan task if it's still running
405        if let Some(handle) = self.scan_task.lock().await.take() {
406            info!("Aborting background workspace scan task");
407            handle.abort();
408            // Wait briefly for the task to finish (don't block shutdown indefinitely)
409            match tokio::time::timeout(std::time::Duration::from_millis(100), handle).await {
410                Ok(Ok(_)) => info!("Background scan task already completed"),
411                Ok(Err(_)) => info!("Background scan task aborted"),
412                Err(_) => info!("Background scan task abort timed out, continuing shutdown"),
413            }
414        }
415
416        info!("Shutdown complete");
417
418        // tower-lsp doesn't always exit cleanly after the exit notification
419        // (serve() may block on stdin/stdout), so we spawn a task to force
420        // exit after a brief delay to allow the shutdown response to be sent.
421        // Skipped during `cargo test` to avoid terminating the test runner.
422        #[cfg(not(test))]
423        tokio::spawn(async {
424            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
425            info!("Forcing process exit");
426            std::process::exit(0);
427        });
428
429        Ok(())
430    }
431}