From f32f5fddbac6767a5ad29207ea727fa4c716103f Mon Sep 17 00:00:00 2001
From: Sofus Addington <sofus@addington.dk>
Date: Mon, 27 Jan 2025 08:22:21 +0100
Subject: [PATCH] Pull diagnostics

---
 helix-core/src/syntax.rs               |   2 +
 helix-lsp/src/client.rs                |  34 +++
 helix-lsp/src/lib.rs                   |   2 +
 helix-term/src/application.rs          |  10 +
 helix-term/src/handlers.rs             |   5 +-
 helix-term/src/handlers/diagnostics.rs | 279 ++++++++++++++++++++++++-
 helix-view/src/document.rs             |   7 +
 helix-view/src/handlers.rs             |   1 +
 helix-view/src/handlers/lsp.rs         |   4 +
 9 files changed, 341 insertions(+), 3 deletions(-)

diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs
index 677cdfa0..9c85dd9e 100644
--- a/helix-core/src/syntax.rs
+++ b/helix-core/src/syntax.rs
@@ -332,6 +332,7 @@ pub enum LanguageServerFeature {
     WorkspaceSymbols,
     // Symbols, use bitflags, see above?
     Diagnostics,
+    PullDiagnostics,
     RenameSymbol,
     InlayHints,
     DocumentColors,
@@ -356,6 +357,7 @@ impl Display for LanguageServerFeature {
             DocumentSymbols => "document-symbols",
             WorkspaceSymbols => "workspace-symbols",
             Diagnostics => "diagnostics",
+            PullDiagnostics => "pull-diagnostics",
             RenameSymbol => "rename-symbol",
             InlayHints => "inlay-hints",
             DocumentColors => "document-colors",
diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs
index f2b78a11..61b05d38 100644
--- a/helix-lsp/src/client.rs
+++ b/helix-lsp/src/client.rs
@@ -348,6 +348,7 @@ impl Client {
                 Some(OneOf::Left(true) | OneOf::Right(_))
             ),
             LanguageServerFeature::Diagnostics => true, // there's no extra server capability
+            LanguageServerFeature::PullDiagnostics => capabilities.diagnostic_provider.is_some(),
             LanguageServerFeature::RenameSymbol => matches!(
                 capabilities.rename_provider,
                 Some(OneOf::Left(true)) | Some(OneOf::Right(_))
@@ -571,6 +572,9 @@ impl Client {
                         did_rename: Some(true),
                         ..Default::default()
                     }),
+                    diagnostic: Some(lsp::DiagnosticWorkspaceClientCapabilities {
+                        refresh_support: Some(true),
+                    }),
                     ..Default::default()
                 }),
                 text_document: Some(lsp::TextDocumentClientCapabilities {
@@ -648,6 +652,10 @@ impl Client {
                         }),
                         ..Default::default()
                     }),
+                    diagnostic: Some(lsp::DiagnosticClientCapabilities {
+                        dynamic_registration: Some(false),
+                        related_document_support: Some(true),
+                    }),
                     publish_diagnostics: Some(lsp::PublishDiagnosticsClientCapabilities {
                         version_support: Some(true),
                         tag_support: Some(lsp::TagSupport {
@@ -1212,6 +1220,32 @@ impl Client {
         Some(self.call::<lsp::request::RangeFormatting>(params))
     }
 
+    pub fn text_document_diagnostic(
+        &self,
+        text_document: lsp::TextDocumentIdentifier,
+        previous_result_id: Option<String>,
+    ) -> Option<impl Future<Output = Result<lsp::DocumentDiagnosticReportResult>>> {
+        let capabilities = self.capabilities();
+
+        // Return early if the server does not support pull diagnostic.
+        let identifier = match capabilities.diagnostic_provider.as_ref()? {
+            lsp::DiagnosticServerCapabilities::Options(cap) => cap.identifier.clone(),
+            lsp::DiagnosticServerCapabilities::RegistrationOptions(cap) => {
+                cap.diagnostic_options.identifier.clone()
+            }
+        };
+
+        let params = lsp::DocumentDiagnosticParams {
+            text_document,
+            identifier,
+            previous_result_id,
+            work_done_progress_params: lsp::WorkDoneProgressParams::default(),
+            partial_result_params: lsp::PartialResultParams::default(),
+        };
+
+        Some(self.call::<lsp::request::DocumentDiagnosticRequest>(params))
+    }
+
     pub fn text_document_document_highlight(
         &self,
         text_document: lsp::TextDocumentIdentifier,
diff --git a/helix-lsp/src/lib.rs b/helix-lsp/src/lib.rs
index 1a2557de..4419f671 100644
--- a/helix-lsp/src/lib.rs
+++ b/helix-lsp/src/lib.rs
@@ -463,6 +463,7 @@ pub enum MethodCall {
     RegisterCapability(lsp::RegistrationParams),
     UnregisterCapability(lsp::UnregistrationParams),
     ShowDocument(lsp::ShowDocumentParams),
+    WorkspaceDiagnosticRefresh,
 }
 
 impl MethodCall {
@@ -494,6 +495,7 @@ impl MethodCall {
                 let params: lsp::ShowDocumentParams = params.parse()?;
                 Self::ShowDocument(params)
             }
+            lsp::request::WorkspaceDiagnosticRefresh::METHOD => Self::WorkspaceDiagnosticRefresh,
             _ => {
                 return Err(Error::Unhandled);
             }
diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs
index 16a26cb2..5e260ba3 100644
--- a/helix-term/src/application.rs
+++ b/helix-term/src/application.rs
@@ -1010,6 +1010,16 @@ impl Application {
                         let result = self.handle_show_document(params, offset_encoding);
                         Ok(json!(result))
                     }
+                    Ok(MethodCall::WorkspaceDiagnosticRefresh) => {
+                        for document in self.editor.documents() {
+                            let language_server = language_server!();
+                            handlers::diagnostics::pull_diagnostics_for_document(
+                                document,
+                                language_server,
+                            );
+                        }
+                        Ok(serde_json::Value::Null)
+                    }
                 };
 
                 let language_server = language_server!();
diff --git a/helix-term/src/handlers.rs b/helix-term/src/handlers.rs
index c7d71526..13f980e3 100644
--- a/helix-term/src/handlers.rs
+++ b/helix-term/src/handlers.rs
@@ -6,6 +6,7 @@ use helix_event::AsyncHook;
 use crate::config::Config;
 use crate::events;
 use crate::handlers::auto_save::AutoSaveHandler;
+use crate::handlers::diagnostics::PullDiagnosticsHandler;
 use crate::handlers::signature_help::SignatureHelpHandler;
 
 pub use helix_view::handlers::Handlers;
@@ -14,7 +15,7 @@ use self::document_colors::DocumentColorsHandler;
 
 mod auto_save;
 pub mod completion;
-mod diagnostics;
+pub mod diagnostics;
 mod document_colors;
 mod signature_help;
 mod snippet;
@@ -26,12 +27,14 @@ pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
     let signature_hints = SignatureHelpHandler::new().spawn();
     let auto_save = AutoSaveHandler::new().spawn();
     let document_colors = DocumentColorsHandler::default().spawn();
+    let pull_diagnostics = PullDiagnosticsHandler::new().spawn();
 
     let handlers = Handlers {
         completions: helix_view::handlers::completion::CompletionHandler::new(event_tx),
         signature_hints,
         auto_save,
         document_colors,
+        pull_diagnostics,
     };
 
     helix_view::handlers::register_hooks(&handlers);
diff --git a/helix-term/src/handlers/diagnostics.rs b/helix-term/src/handlers/diagnostics.rs
index 3e44d416..6b0784bf 100644
--- a/helix-term/src/handlers/diagnostics.rs
+++ b/helix-term/src/handlers/diagnostics.rs
@@ -1,12 +1,24 @@
+use std::time::Duration;
+
+use helix_core::diagnostic::DiagnosticProvider;
+use helix_core::syntax::LanguageServerFeature;
+use helix_core::Uri;
 use helix_event::{register_hook, send_blocking};
+use helix_lsp::lsp;
 use helix_view::document::Mode;
-use helix_view::events::DiagnosticsDidChange;
+use helix_view::events::{
+    DiagnosticsDidChange, DocumentDidChange, DocumentDidOpen, LanguageServerInitialized,
+};
 use helix_view::handlers::diagnostics::DiagnosticEvent;
+use helix_view::handlers::lsp::PullDiagnosticsEvent;
 use helix_view::handlers::Handlers;
+use helix_view::{DocumentId, Editor};
+use tokio::time::Instant;
 
 use crate::events::OnModeSwitch;
+use crate::job;
 
-pub(super) fn register_hooks(_handlers: &Handlers) {
+pub(super) fn register_hooks(handlers: &Handlers) {
     register_hook!(move |event: &mut DiagnosticsDidChange<'_>| {
         if event.editor.mode != Mode::Insert {
             for (view, _) in event.editor.tree.views_mut() {
@@ -21,4 +33,267 @@ pub(super) fn register_hooks(_handlers: &Handlers) {
         }
         Ok(())
     });
+
+    let tx = handlers.pull_diagnostics.clone();
+    register_hook!(move |event: &mut DocumentDidChange<'_>| {
+        if event
+            .doc
+            .has_language_server_with_feature(LanguageServerFeature::PullDiagnostics)
+        {
+            let document_id = event.doc.id();
+            send_blocking(&tx, PullDiagnosticsEvent { document_id });
+        }
+        Ok(())
+    });
+
+    register_hook!(move |event: &mut DocumentDidOpen<'_>| {
+        let doc = doc!(event.editor, &event.doc);
+        for language_server in
+            doc.language_servers_with_feature(LanguageServerFeature::PullDiagnostics)
+        {
+            pull_diagnostics_for_document(doc, language_server);
+        }
+
+        Ok(())
+    });
+
+    register_hook!(move |event: &mut LanguageServerInitialized<'_>| {
+        let language_server = event.editor.language_server_by_id(event.server_id).unwrap();
+        if language_server.supports_feature(LanguageServerFeature::PullDiagnostics) {
+            for doc in event
+                .editor
+                .documents()
+                .filter(|doc| doc.supports_language_server(event.server_id))
+            {
+                pull_diagnostics_for_document(doc, language_server);
+            }
+        }
+
+        Ok(())
+    });
+}
+
+#[derive(Debug)]
+pub(super) struct PullDiagnosticsHandler {
+    no_inter_file_dependency_timeout: Option<tokio::time::Instant>,
+}
+
+impl PullDiagnosticsHandler {
+    pub fn new() -> PullDiagnosticsHandler {
+        PullDiagnosticsHandler {
+            no_inter_file_dependency_timeout: None,
+        }
+    }
+}
+
+const TIMEOUT: Duration = Duration::from_millis(500);
+const TIMEOUT_NO_INTER_FILE_DEPENDENCY: Duration = Duration::from_millis(125);
+
+impl helix_event::AsyncHook for PullDiagnosticsHandler {
+    type Event = PullDiagnosticsEvent;
+
+    fn handle_event(
+        &mut self,
+        event: Self::Event,
+        timeout: Option<tokio::time::Instant>,
+    ) -> Option<tokio::time::Instant> {
+        if timeout.is_none() {
+            dispatch_pull_diagnostic_for_document(event.document_id, false);
+            self.no_inter_file_dependency_timeout = Some(Instant::now());
+        }
+
+        if self
+            .no_inter_file_dependency_timeout
+            .is_some_and(|nifd_timeout| {
+                nifd_timeout.duration_since(Instant::now()) > TIMEOUT_NO_INTER_FILE_DEPENDENCY
+            })
+        {
+            dispatch_pull_diagnostic_for_document(event.document_id, true);
+            self.no_inter_file_dependency_timeout = Some(Instant::now());
+        };
+
+        Some(Instant::now() + TIMEOUT)
+    }
+
+    fn finish_debounce(&mut self) {
+        dispatch_pull_diagnostic_for_open_documents();
+    }
+}
+
+fn dispatch_pull_diagnostic_for_document(
+    document_id: DocumentId,
+    exclude_language_servers_without_inter_file_dependency: bool,
+) {
+    job::dispatch_blocking(move |editor, _| {
+        let Some(doc) = editor.document(document_id) else {
+            return;
+        };
+
+        let language_servers = doc
+            .language_servers_with_feature(LanguageServerFeature::PullDiagnostics)
+            .filter(|ls| ls.is_initialized())
+            .filter(|ls| {
+                if !exclude_language_servers_without_inter_file_dependency {
+                    return true;
+                };
+                ls.capabilities()
+                    .diagnostic_provider
+                    .as_ref()
+                    .is_some_and(|dp| match dp {
+                        lsp::DiagnosticServerCapabilities::Options(options) => {
+                            options.inter_file_dependencies
+                        }
+                        lsp::DiagnosticServerCapabilities::RegistrationOptions(options) => {
+                            options.diagnostic_options.inter_file_dependencies
+                        }
+                    })
+            });
+
+        for language_server in language_servers {
+            pull_diagnostics_for_document(doc, language_server);
+        }
+    })
+}
+
+fn dispatch_pull_diagnostic_for_open_documents() {
+    job::dispatch_blocking(move |editor, _| {
+        let documents = editor.documents.values();
+
+        for document in documents {
+            let language_servers = document
+                .language_servers_with_feature(LanguageServerFeature::PullDiagnostics)
+                .filter(|ls| ls.is_initialized());
+
+            for language_server in language_servers {
+                pull_diagnostics_for_document(document, language_server);
+            }
+        }
+    })
+}
+
+pub fn pull_diagnostics_for_document(
+    doc: &helix_view::Document,
+    language_server: &helix_lsp::Client,
+) {
+    let Some(future) = language_server
+        .text_document_diagnostic(doc.identifier(), doc.previous_diagnostic_id.clone())
+    else {
+        return;
+    };
+
+    let Some(uri) = doc.uri() else {
+        return;
+    };
+
+    let identifier = language_server
+        .capabilities()
+        .diagnostic_provider
+        .as_ref()
+        .and_then(|diagnostic_provider| match diagnostic_provider {
+            lsp::DiagnosticServerCapabilities::Options(options) => options.identifier.clone(),
+            lsp::DiagnosticServerCapabilities::RegistrationOptions(options) => {
+                options.diagnostic_options.identifier.clone()
+            }
+        });
+
+    let language_server_id = language_server.id();
+    let provider = DiagnosticProvider::Lsp {
+        server_id: language_server_id,
+        identifier,
+    };
+    let document_id = doc.id();
+
+    tokio::spawn(async move {
+        match future.await {
+            Ok(result) => {
+                job::dispatch(move |editor, _| {
+                    handle_pull_diagnostics_response(editor, result, provider, uri, document_id)
+                })
+                .await
+            }
+            Err(err) => {
+                let parsed_cancellation_data = if let helix_lsp::Error::Rpc(error) = err {
+                    error.data.and_then(|data| {
+                        serde_json::from_value::<lsp::DiagnosticServerCancellationData>(data).ok()
+                    })
+                } else {
+                    log::error!("Pull diagnostic request failed: {err}");
+                    return;
+                };
+
+                if let Some(parsed_cancellation_data) = parsed_cancellation_data {
+                    if parsed_cancellation_data.retrigger_request {
+                        tokio::time::sleep(Duration::from_millis(500)).await;
+
+                        job::dispatch(move |editor, _| {
+                            if let (Some(doc), Some(language_server)) = (
+                                editor.document(document_id),
+                                editor.language_server_by_id(language_server_id),
+                            ) {
+                                pull_diagnostics_for_document(doc, language_server);
+                            }
+                        })
+                        .await;
+                    }
+                }
+            }
+        }
+    });
+}
+
+fn handle_pull_diagnostics_response(
+    editor: &mut Editor,
+    result: lsp::DocumentDiagnosticReportResult,
+    provider: DiagnosticProvider,
+    uri: Uri,
+    document_id: DocumentId,
+) {
+    let related_documents = match result {
+        lsp::DocumentDiagnosticReportResult::Report(report) => {
+            let (result_id, related_documents) = match report {
+                lsp::DocumentDiagnosticReport::Full(report) => {
+                    editor.handle_lsp_diagnostics(
+                        &provider,
+                        uri,
+                        None,
+                        report.full_document_diagnostic_report.items,
+                    );
+
+                    (
+                        report.full_document_diagnostic_report.result_id,
+                        report.related_documents,
+                    )
+                }
+                lsp::DocumentDiagnosticReport::Unchanged(report) => (
+                    Some(report.unchanged_document_diagnostic_report.result_id),
+                    report.related_documents,
+                ),
+            };
+
+            if let Some(doc) = editor.document_mut(document_id) {
+                doc.previous_diagnostic_id = result_id;
+            };
+
+            related_documents
+        }
+        lsp::DocumentDiagnosticReportResult::Partial(report) => report.related_documents,
+    };
+
+    for (url, report) in related_documents.into_iter().flatten() {
+        let result_id = match report {
+            lsp::DocumentDiagnosticReportKind::Full(report) => {
+                let Ok(uri) = Uri::try_from(url) else {
+                    continue;
+                };
+
+                editor.handle_lsp_diagnostics(&provider, uri, None, report.items);
+                report.result_id
+            }
+            lsp::DocumentDiagnosticReportKind::Unchanged(report) => Some(report.result_id),
+        };
+
+        if let Some(doc) = editor.document_mut(document_id) {
+            doc.previous_diagnostic_id = result_id;
+        }
+    }
 }
diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs
index 41c9ee1e..7833fd98 100644
--- a/helix-view/src/document.rs
+++ b/helix-view/src/document.rs
@@ -202,6 +202,8 @@ pub struct Document {
 
     pub readonly: bool,
 
+    pub previous_diagnostic_id: Option<String>,
+
     /// Annotations for LSP document color swatches
     pub color_swatches: Option<DocumentColorSwatches>,
     // NOTE: ideally this would live on the handler for color swatches. This is blocked on a
@@ -719,6 +721,7 @@ impl Document {
             jump_labels: HashMap::new(),
             color_swatches: None,
             color_swatch_controller: TaskController::new(),
+            previous_diagnostic_id: None,
         }
     }
 
@@ -2247,6 +2250,10 @@ impl Document {
     pub fn reset_all_inlay_hints(&mut self) {
         self.inlay_hints = Default::default();
     }
+
+    pub fn has_language_server_with_feature(&self, feature: LanguageServerFeature) -> bool {
+        self.language_servers_with_feature(feature).next().is_some()
+    }
 }
 
 #[derive(Debug, Default)]
diff --git a/helix-view/src/handlers.rs b/helix-view/src/handlers.rs
index 258ed89e..6563c209 100644
--- a/helix-view/src/handlers.rs
+++ b/helix-view/src/handlers.rs
@@ -22,6 +22,7 @@ pub struct Handlers {
     pub signature_hints: Sender<lsp::SignatureHelpEvent>,
     pub auto_save: Sender<AutoSaveEvent>,
     pub document_colors: Sender<lsp::DocumentColorsEvent>,
+    pub pull_diagnostics: Sender<lsp::PullDiagnosticsEvent>,
 }
 
 impl Handlers {
diff --git a/helix-view/src/handlers/lsp.rs b/helix-view/src/handlers/lsp.rs
index c1041b2a..073d9581 100644
--- a/helix-view/src/handlers/lsp.rs
+++ b/helix-view/src/handlers/lsp.rs
@@ -30,6 +30,10 @@ pub enum SignatureHelpEvent {
     RequestComplete { open: bool },
 }
 
+pub struct PullDiagnosticsEvent {
+    pub document_id: DocumentId,
+}
+
 #[derive(Debug)]
 pub struct ApplyEditError {
     pub kind: ApplyEditErrorKind,