Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions crates/pgls_lsp/src/handlers/text_document.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand All @@ -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(&params.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;
Expand Down Expand Up @@ -67,22 +73,26 @@ 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(())
}

/// 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 })?;
Expand Down
62 changes: 62 additions & 0 deletions crates/pgls_lsp/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -78,6 +88,13 @@ pub(crate) struct Session {

/// Extra configuration from environment variables, applied on every config load.
env_config: Option<PartialConfiguration>,

/// 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<Mutex<FxHashMap<lsp_types::Url, AbortHandle>>>,
}

/// The parameters provided by the client in the "initialize" request
Expand Down Expand Up @@ -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())),
}
}

Expand Down Expand Up @@ -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<Self>, 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
Expand Down
Loading