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                ..Default::default()
155            },
156        })
157    }
158
159    async fn initialized(&self, _: InitializedParams) {
160        info!("Server initialized notification received");
161        self.client
162            .log_message(MessageType::INFO, "pytest-language-server initialized")
163            .await;
164
165        // Register a file watcher for __init__.py create/delete events.
166        // When package markers change, `file_path_to_module_path()` results
167        // (captured in `FixtureDefinition::return_type_imports`) become stale,
168        // so we re-analyze affected fixture files to refresh them.
169        let watch_init_py = Registration {
170            id: "watch-init-py".to_string(),
171            method: "workspace/didChangeWatchedFiles".to_string(),
172            register_options: Some(
173                serde_json::to_value(DidChangeWatchedFilesRegistrationOptions {
174                    watchers: vec![FileSystemWatcher {
175                        glob_pattern: GlobPattern::String("**/__init__.py".to_string()),
176                        kind: Some(WatchKind::Create | WatchKind::Delete),
177                    }],
178                })
179                .unwrap(),
180            ),
181        };
182
183        if let Err(e) = self.client.register_capability(vec![watch_init_py]).await {
184            // Not fatal — file watching is best-effort.  The user can still
185            // manually re-open fixture files to trigger re-analysis.
186            info!(
187                "Failed to register __init__.py file watcher (client may not support it): {}",
188                e
189            );
190        }
191    }
192
193    async fn did_open(&self, params: DidOpenTextDocumentParams) {
194        let uri = params.text_document.uri.clone();
195        info!("did_open: {:?}", uri);
196        if let Some(file_path) = self.uri_to_path(&uri) {
197            // Cache the original URI for this canonical path
198            // This ensures we respond with URIs the client recognizes
199            self.uri_cache.insert(file_path.clone(), uri.clone());
200
201            info!("Analyzing file: {:?}", file_path);
202            self.fixture_db
203                .analyze_file(file_path.clone(), &params.text_document.text);
204
205            // Publish diagnostics for undeclared fixtures
206            self.publish_diagnostics_for_file(&uri, &file_path).await;
207        }
208    }
209
210    async fn did_change(&self, params: DidChangeTextDocumentParams) {
211        let uri = params.text_document.uri.clone();
212        info!("did_change: {:?}", uri);
213        if let Some(file_path) = self.uri_to_path(&uri) {
214            if let Some(change) = params.content_changes.first() {
215                info!("Re-analyzing file: {:?}", file_path);
216                self.fixture_db
217                    .analyze_file(file_path.clone(), &change.text);
218
219                // Publish diagnostics for undeclared fixtures
220                self.publish_diagnostics_for_file(&uri, &file_path).await;
221
222                // Request inlay hint refresh so editors update hints after edits
223                // (e.g., when user adds/removes type annotations)
224                if let Err(e) = self.client.inlay_hint_refresh().await {
225                    // Not all clients support this, so just log and continue
226                    info!(
227                        "Inlay hint refresh request failed (client may not support it): {}",
228                        e
229                    );
230                }
231            }
232        }
233    }
234
235    async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
236        // Re-analyze fixture files whose `return_type_imports` may have become
237        // stale because an `__init__.py` was created or deleted, changing the
238        // result of `file_path_to_module_path()`.
239        for event in &params.changes {
240            if event.typ != FileChangeType::CREATED && event.typ != FileChangeType::DELETED {
241                continue;
242            }
243
244            let Some(init_path) = self.uri_to_path(&event.uri) else {
245                continue;
246            };
247
248            // The __init__.py change affects the directory it lives in and
249            // every directory below it.  Any fixture file at or under that
250            // directory may produce a different module path now.
251            let affected_dir = match init_path.parent() {
252                Some(dir) => dir.to_path_buf(),
253                None => continue,
254            };
255
256            let kind = if event.typ == FileChangeType::CREATED {
257                "created"
258            } else {
259                "deleted"
260            };
261            info!(
262                "__init__.py {} in {:?} — re-analyzing affected fixture files",
263                kind, affected_dir
264            );
265
266            // Collect fixture files that live at or below the affected directory.
267            let files_to_reanalyze: Vec<std::path::PathBuf> = self
268                .fixture_db
269                .file_definitions
270                .iter()
271                .filter(|entry| entry.key().starts_with(&affected_dir))
272                .map(|entry| entry.key().clone())
273                .collect();
274
275            for file_path in files_to_reanalyze {
276                if let Some(content) = self.fixture_db.get_file_content(&file_path) {
277                    info!("Re-analyzing {:?} after __init__.py change", file_path);
278                    self.fixture_db.analyze_file(file_path.clone(), &content);
279
280                    // Re-publish diagnostics for the file if we have a cached URI.
281                    if let Some(uri) = self.uri_cache.get(&file_path) {
282                        self.publish_diagnostics_for_file(&uri, &file_path).await;
283                    }
284                }
285            }
286        }
287
288        // Refresh inlay hints in case return types changed.
289        if !params.changes.is_empty() {
290            if let Err(e) = self.client.inlay_hint_refresh().await {
291                info!(
292                    "Inlay hint refresh after __init__.py change failed (client may not support it): {}",
293                    e
294                );
295            }
296        }
297    }
298
299    async fn did_close(&self, params: DidCloseTextDocumentParams) {
300        let uri = params.text_document.uri;
301        info!("did_close: {:?}", uri);
302        if let Some(file_path) = self.uri_to_path(&uri) {
303            // Clean up cached data for this file to prevent unbounded memory growth
304            self.fixture_db.cleanup_file_cache(&file_path);
305            // Clean up URI cache entry
306            self.uri_cache.remove(&file_path);
307        }
308    }
309
310    async fn goto_definition(
311        &self,
312        params: GotoDefinitionParams,
313    ) -> Result<Option<GotoDefinitionResponse>> {
314        self.handle_goto_definition(params).await
315    }
316
317    async fn goto_implementation(
318        &self,
319        params: GotoImplementationParams,
320    ) -> Result<Option<GotoImplementationResponse>> {
321        self.handle_goto_implementation(params).await
322    }
323
324    async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
325        self.handle_hover(params).await
326    }
327
328    async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
329        self.handle_references(params).await
330    }
331
332    async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
333        self.handle_completion(params).await
334    }
335
336    async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
337        self.handle_code_action(params).await
338    }
339
340    async fn document_symbol(
341        &self,
342        params: DocumentSymbolParams,
343    ) -> Result<Option<DocumentSymbolResponse>> {
344        self.handle_document_symbol(params).await
345    }
346
347    async fn symbol(
348        &self,
349        params: WorkspaceSymbolParams,
350    ) -> Result<Option<WorkspaceSymbolResponse>> {
351        let result = self.handle_workspace_symbol(params).await?;
352        Ok(result.map(WorkspaceSymbolResponse::Flat))
353    }
354
355    async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
356        self.handle_code_lens(params).await
357    }
358
359    async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
360        self.handle_inlay_hint(params).await
361    }
362
363    async fn prepare_call_hierarchy(
364        &self,
365        params: CallHierarchyPrepareParams,
366    ) -> Result<Option<Vec<CallHierarchyItem>>> {
367        self.handle_prepare_call_hierarchy(params).await
368    }
369
370    async fn incoming_calls(
371        &self,
372        params: CallHierarchyIncomingCallsParams,
373    ) -> Result<Option<Vec<CallHierarchyIncomingCall>>> {
374        self.handle_incoming_calls(params).await
375    }
376
377    async fn outgoing_calls(
378        &self,
379        params: CallHierarchyOutgoingCallsParams,
380    ) -> Result<Option<Vec<CallHierarchyOutgoingCall>>> {
381        self.handle_outgoing_calls(params).await
382    }
383
384    async fn shutdown(&self) -> Result<()> {
385        info!("Shutdown request received");
386
387        // Cancel the background scan task if it's still running
388        if let Some(handle) = self.scan_task.lock().await.take() {
389            info!("Aborting background workspace scan task");
390            handle.abort();
391            // Wait briefly for the task to finish (don't block shutdown indefinitely)
392            match tokio::time::timeout(std::time::Duration::from_millis(100), handle).await {
393                Ok(Ok(_)) => info!("Background scan task already completed"),
394                Ok(Err(_)) => info!("Background scan task aborted"),
395                Err(_) => info!("Background scan task abort timed out, continuing shutdown"),
396            }
397        }
398
399        info!("Shutdown complete");
400
401        // tower-lsp doesn't always exit cleanly after the exit notification
402        // (serve() may block on stdin/stdout), so we spawn a task to force
403        // exit after a brief delay to allow the shutdown response to be sent.
404        // Skipped during `cargo test` to avoid terminating the test runner.
405        #[cfg(not(test))]
406        tokio::spawn(async {
407            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
408            info!("Forcing process exit");
409            std::process::exit(0);
410        });
411
412        Ok(())
413    }
414}