diff --git a/crates/pgls_lsp/src/handlers/text_document.rs b/crates/pgls_lsp/src/handlers/text_document.rs index c02c6fbb6..df3abdef6 100644 --- a/crates/pgls_lsp/src/handlers/text_document.rs +++ b/crates/pgls_lsp/src/handlers/text_document.rs @@ -1,4 +1,4 @@ -use crate::{documents::Document, session::Session, utils::apply_document_changes}; +use crate::{documents::Document, session::SessionHandle, utils::apply_document_changes}; use anyhow::Result; use pgls_workspace::workspace::{ ChangeFileParams, CloseFileParams, GetFileContentParams, OpenFileParams, @@ -9,7 +9,7 @@ use tracing::{error, field}; /// Handler for `textDocument/didOpen` LSP notification #[tracing::instrument(level = "debug", skip(session), err)] pub(crate) async fn did_open( - session: &Session, + session: &SessionHandle, params: lsp_types::DidOpenTextDocumentParams, ) -> Result<()> { let url = params.text_document.uri; @@ -35,9 +35,15 @@ pub(crate) async fn did_open( } /// Handler for `textDocument/didChange` LSP notification +/// +/// Document content is updated synchronously so other LSP features (hover, +/// completion) see the latest text immediately. The expensive diagnostic +/// analysis is scheduled via [`SessionHandle::schedule_diagnostics`], which +/// debounces rapid-fire changes so that only one analysis run fires at the +/// end of a burst rather than one per keystroke. #[tracing::instrument(level = "debug", skip_all, fields(url = field::display(¶ms.text_document.uri), version = params.text_document.version), err)] pub(crate) async fn did_change( - session: &Session, + session: &SessionHandle, params: lsp_types::DidChangeTextDocumentParams, ) -> Result<()> { let url = params.text_document.uri; @@ -67,9 +73,9 @@ pub(crate) async fn did_change( content: text, })?; - if let Err(err) = session.update_diagnostics(url).await { - error!("Failed to update diagnostics: {}", err); - } + // Schedule debounced diagnostics instead of running immediately. + // This prevents a keystroke burst from queuing one analysis per change. + session.schedule_diagnostics(url); Ok(()) } @@ -77,12 +83,16 @@ pub(crate) async fn did_change( /// Handler for `textDocument/didClose` LSP notification #[tracing::instrument(level = "debug", skip(session), err)] pub(crate) async fn did_close( - session: &Session, + session: &SessionHandle, params: lsp_types::DidCloseTextDocumentParams, ) -> Result<()> { let url = params.text_document.uri; let pgls_path = session.file_path(&url)?; + // Cancel any pending debounced diagnostic task before removing the document + // so that no stale diagnostics are published after the file is closed. + session.cancel_pending_diagnostics(&url); + session .workspace .close_file(CloseFileParams { path: pgls_path })?; diff --git a/crates/pgls_lsp/src/session.rs b/crates/pgls_lsp/src/session.rs index 720c1fa9b..1b7dd376f 100644 --- a/crates/pgls_lsp/src/session.rs +++ b/crates/pgls_lsp/src/session.rs @@ -20,17 +20,27 @@ use rustc_hash::FxHashMap; use serde_json::Value; use std::path::PathBuf; use std::sync::Arc; +use std::sync::Mutex; use std::sync::RwLock; use std::sync::atomic::Ordering; use std::sync::atomic::{AtomicBool, AtomicU8}; +use std::time::Duration; use tokio::sync::Notify; use tokio::sync::OnceCell; +use tokio::task::AbortHandle; use tower_lsp::lsp_types::Url; use tower_lsp::lsp_types::{self, ClientCapabilities}; use tower_lsp::lsp_types::{MessageType, Registration}; use tower_lsp::lsp_types::{Unregistration, WorkspaceFolder}; use tracing::{debug, error, info}; +/// Debounce delay for `textDocument/didChange` diagnostics. +/// +/// After each keystroke the server waits this long before running analysis. +/// If another change arrives within the window the previous task is cancelled +/// and the timer resets, so only one analysis run fires per burst. +const DIAGNOSTIC_DEBOUNCE: Duration = Duration::from_millis(300); + pub(crate) struct ClientInformation { #[allow(dead_code)] /// The name of the client @@ -78,6 +88,13 @@ pub(crate) struct Session { /// Extra configuration from environment variables, applied on every config load. env_config: Option, + + /// Per-URL abort handles for pending debounced diagnostic tasks. + /// + /// When `did_change` fires, any in-flight handle for the URL is aborted + /// before a new task is spawned. This ensures that only one analysis run + /// fires at the end of a rapid-fire burst rather than one per keystroke. + diagnostic_debounce: Arc>>, } /// The parameters provided by the client in the "initialize" request @@ -174,6 +191,7 @@ impl Session { notified_broken_configuration: AtomicBool::new(false), notified_deprecated_config: AtomicBool::new(false), env_config, + diagnostic_debounce: Arc::new(Mutex::new(FxHashMap::default())), } } @@ -325,6 +343,50 @@ impl Session { Ok(()) } + /// Schedules a debounced diagnostic update for the given URL. + /// + /// If a diagnostic task for `url` is already pending it is aborted first, + /// resetting the debounce window. A new task is spawned that waits + /// [`DIAGNOSTIC_DEBOUNCE`] before calling [`Self::update_diagnostics`]. + /// This prevents a rapid-fire `textDocument/didChange` burst from queuing + /// one analysis run per keystroke. + pub(crate) fn schedule_diagnostics(self: &Arc, url: lsp_types::Url) { + // Cancel any pending task for this URL. + if let Some(handle) = self.diagnostic_debounce.lock().unwrap().remove(&url) { + handle.abort(); + } + + let session = Arc::clone(self); + let url_clone = url.clone(); + let join_handle = tokio::spawn(async move { + tokio::time::sleep(DIAGNOSTIC_DEBOUNCE).await; + // Remove our own entry so stale handles don't accumulate. + session + .diagnostic_debounce + .lock() + .unwrap() + .remove(&url_clone); + if let Err(err) = session.update_diagnostics(url_clone).await { + error!("Failed to update diagnostics: {}", err); + } + }); + + self.diagnostic_debounce + .lock() + .unwrap() + .insert(url, join_handle.abort_handle()); + } + + /// Cancels any pending debounced diagnostic task for `url`. + /// + /// Called from `did_close` to ensure no diagnostics are published after + /// the document has been closed. + pub(crate) fn cancel_pending_diagnostics(&self, url: &lsp_types::Url) { + if let Some(handle) = self.diagnostic_debounce.lock().unwrap().remove(url) { + handle.abort(); + } + } + /// Updates diagnostics for every [`Document`] in this [`Session`] pub(crate) async fn update_all_diagnostics(&self) { let mut futures: FuturesUnordered<_> = self