From 4aff16bf9e8be9e45b5ac5b98a323957e3af6444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20T=C3=A1vora?= Date: Mon, 15 Dec 2025 17:14:41 +0000 Subject: [PATCH] Eglot: improve pull diagnostics support * lisp/progmodes/eglot.el (eglot--diagnostics): Move up here. (eglot--managed-mode): Use eglot--flymake-push. (eglot-handle-notification): Simplify. (eglot-flymake-backend): Simplify. (eglot--flymake-pull): Rewrite. (eglot--flymake-push): Tweak. * etc/EGLOT-NEWS: Improve slightly. --- etc/EGLOT-NEWS | 13 +--- lisp/progmodes/eglot.el | 157 +++++++++++++++++++++++----------------- 2 files changed, 93 insertions(+), 77 deletions(-) diff --git a/etc/EGLOT-NEWS b/etc/EGLOT-NEWS index fbd1a87ec5b..fea878a47ca 100644 --- a/etc/EGLOT-NEWS +++ b/etc/EGLOT-NEWS @@ -23,15 +23,10 @@ https://github.com/joaotavora/eglot/issues/1234. ** Support for pull diagnostics (github#1559, github#1290) For servers supporting the 'diagnosticProvider' capability, Eglot -requests diagnostics on-demand rather than relying solely on -server-pushed 'publishDiagnostics' notifications. This may provide a -more responsive user experience. The 'ty' server is known to support it -while the 'tsgo' server is known to support is exclusively. However, no -server has been found to support the 'relatedDocumentSupport' -sub-capability, which e.g. introducing a problem in one file to bring -diagnostics for another related file. The traditional push diagnostics, -as supported by some servers, are at the moment still a superior user -experience in this particular regard. +requests diagnostics explicitly rather than relying on sporadic +'publishDiagnostics' notifications, aka. "push diagnostics". The 'tsgo' +server is known to support the "pull" variant exclusively, while the +'ty' server is known to support it alongside "push". ** Support for semantic tokens (bug#79374) diff --git a/lisp/progmodes/eglot.el b/lisp/progmodes/eglot.el index 2f140ff510a..aec949fc5b1 100644 --- a/lisp/progmodes/eglot.el +++ b/lisp/progmodes/eglot.el @@ -2221,6 +2221,14 @@ Use `eglot-managed-p' to determine if current buffer is managed.") (defvar eglot--highlights nil "Overlays for `eglot-highlight-eldoc-function'.") +(defvar-local eglot--diagnostics nil + "A list (DIAGNOSTICS VERSION RESULT-ID) for current buffer. +DIAGNOSTICS is a list of Flymake diagnostics objects. VERSION is the +LSP Document version reported for DIAGNOSTICS (comparable to +`eglot--docver') or nil if server didn't bother. RESULT-ID is an +optional string identifying this diagnostic result for pull +diagnostics, used for incremental updates.") + (defvar-local eglot--suggestion-overlay (make-overlay 0 0) "Overlay for `eglot-code-action-suggestion'.") @@ -2300,7 +2308,8 @@ Use `eglot-managed-p' to determine if current buffer is managed.") do (set (make-local-variable var) saved-binding)) (remove-function (local 'imenu-create-index-function) #'eglot-imenu) (when eglot--flymake-push-report-fn - (eglot--flymake-push nil nil) + (setq eglot--diagnostics nil) + (eglot--flymake-push) (setq eglot--flymake-push-report-fn nil)) (run-hooks 'eglot-managed-mode-hook) (let ((server eglot--cached-server)) @@ -2337,12 +2346,6 @@ Use `eglot-managed-p' to determine if current buffer is managed.") (or (eglot-current-server) (jsonrpc-error "No current JSON-RPC connection"))) -(defvar-local eglot--diagnostics nil - "A cons (DIAGNOSTICS . VERSION) for current buffer. -DIAGNOSTICS is a list of Flymake diagnostics objects. VERSION is the -LSP Document version reported for DIAGNOSTICS (comparable to -`eglot--docver') or nil if server didn't bother.") - (defvar revert-buffer-preserve-modes) (defvar eglot-semantic-tokens-mode) ;; forward decl (defun eglot--after-revert-hook () @@ -2780,14 +2783,12 @@ expensive cached value of `file-truename'.") for diag-spec across diagnostics collect (eglot--flymake-make-diag diag-spec version) into diags - finally (cond ((and - ;; only add to current report if Flymake - ;; starts on idle-timer (github#958) - (not (null flymake-no-changes-timeout)) - eglot--flymake-push-report-fn) - (eglot--flymake-push diags version)) - (t - (setq eglot--diagnostics (cons diags version)))))) + finally + (setq eglot--diagnostics (list diags version nil)) + (when (not (null flymake-no-changes-timeout )) + ;; only add to current report if Flymake + ;; starts on idle-timer (github#957) + (eglot--flymake-push)))) (cl-loop for diag-spec across diagnostics collect (eglot--dbind ((Diagnostic) code range message severity source) diag-spec @@ -3229,66 +3230,86 @@ publishes diagnostics. Between calls to this function, REPORT-FN may be called multiple times (respecting the protocol of `flymake-diagnostic-functions')." (cond (eglot--managed-mode + (setq eglot--flymake-push-report-fn report-fn) (cond ;; Use pull diagnostics if server supports it ((eglot-server-capable :diagnosticProvider) - (eglot--flymake-pull report-fn)) - ;; Otherwise use traditional push diagnostics - (t - (setq eglot--flymake-push-report-fn report-fn) - (eglot--flymake-push (car eglot--diagnostics) - (cdr eglot--diagnostics))))) + (eglot--flymake-pull)) + ;; Otherwise push whatever we might have, and wait for + ;; `textDocument/publishDiagnostics'. + (t (eglot--flymake-push)))) (t (funcall report-fn nil)))) -(defun eglot--flymake-pull (report-fn) - "Pull diagnostics from server and call REPORT-FN." - (let ((buf (current-buffer)) - (server (eglot--current-server-or-lose)) - (version eglot--docver)) - (eglot--async-request - server - :textDocument/diagnostic - (list :textDocument (eglot--TextDocumentIdentifier)) - :success-fn - (lambda (result) - (eglot--when-live-buffer buf - (eglot--dbind ((DocumentDiagnosticReport) kind items) result - (pcase kind - ("full" - (let ((diags - (cl-loop - for diag-spec across items - collect (eglot--flymake-make-diag diag-spec version)))) - (setq eglot--diagnostics (cons diags version)))) - ("unchanged" - ;; Server says diagnostics haven't changed, report what we have - )) - (funcall report-fn (car eglot--diagnostics) - :region (cons (point-min) (point-max)))))) - :hint :textDocument/diagnostic))) +(cl-defun eglot--flymake-pull (&aux (server (eglot--current-server-or-lose)) + (origin (current-buffer))) + "Pull diagnostics from server, for all managed buffers. +When response arrives call registered `eglot--flymake-push-report-fn'." + (cl-flet + ((pull-for (buf &optional then) + (with-current-buffer buf + (let ((version eglot--docver) + (prev-result-id (nth 2 eglot--diagnostics))) + (eglot--async-request + server + :textDocument/diagnostic + (append + `(:textDocument ,(eglot--TextDocumentIdentifier) + ,@(when prev-result-id + `(:previousResultId ,prev-result-id)))) + :success-fn + (eglot--lambda ((DocumentDiagnosticReport) kind items resultId) + (eglot--when-live-buffer buf + (pcase kind + ("full" + (setq eglot--diagnostics + (list + (cl-loop + for spec across items + collect (eglot--flymake-make-diag spec version)) + version + resultId)) + (eglot--flymake-push)) + ("unchanged" + (when (eq buf origin) (eglot--flymake-push 'void))))) + (when then (funcall then))) + :hint :textDocument/diagnostic))))) + ;; JT@2025-12-15: No known server yet supports "relatedDocuments" so + ;; the only way we have to get related diagnostics is to explicitly + ;; request them of all open documents. Moreover, experience has + ;; shown this needs to happen after the 'origin''s response. + (pull-for origin + (unless (zerop eglot--docver) + (lambda () + (mapc #'pull-for + (remove origin (eglot--managed-buffers server)))))))) -(defun eglot--flymake-push (diags version) - "Push previously collected diagnostics to `eglot--flymake-push-report-fn'." - (save-restriction - (widen) - (if (or (null version) (= version eglot--docver)) - (funcall eglot--flymake-push-report-fn diags - ;; If the buffer hasn't changed since last - ;; call to the report function, flymake won't - ;; delete old diagnostics. Using :region - ;; keyword forces flymake to delete - ;; them (github#159). - :region (cons (point-min) (point-max))) - ;; Here, we don't have anything up to date to give Flymake: we - ;; just want to keep whatever diagnostics it has annotated in - ;; the buffer. However, as a nice-to-have, we still want to - ;; signal we're alive and clear a possible "Wait" state. We - ;; hackingly achieve this by reporting an empty list and making - ;; sure it pertains to a 0-length region. - (funcall eglot--flymake-push-report-fn nil - :region (cons (point-min) (point-min))))) - (setq eglot--diagnostics (cons diags version))) +(cl-defun eglot--flymake-push + (&optional void &aux (diags (nth 0 eglot--diagnostics)) + (version (nth 1 eglot--diagnostics))) + "Push previously collected diagnostics to `eglot--flymake-push-report-fn'. +If VOID, knowingly push a dummy do-nothing update." + (unless eglot--flymake-push-report-fn + ;; Occasionally called from contexts where report-fn not setup, such + ;; as a `didOpen''ed but yet undisplayed buffer. + (cl-return-from eglot--flymake-push)) + (eglot--widening + (if (or void (and version (< version eglot--docver))) + ;; Here, we don't have anything interesting to give to Flymake: we + ;; just want to keep whatever diagnostics it has annotated in the + ;; buffer. However, as a nice-to-have, we still want to signal + ;; we're alive and clear a possible "Wait" state. We hackingly + ;; achieve this by reporting an empty list and making sure it + ;; pertains to a 0-length region. + (funcall eglot--flymake-push-report-fn nil + :region (cons (point-min) (point-min))) + (funcall eglot--flymake-push-report-fn diags + ;; If the buffer hasn't changed since last + ;; call to the report function, flymake won't + ;; delete old diagnostics. Using :region + ;; keyword forces flymake to delete + ;; them (github#159). + :region (cons (point-min) (point-max)))))) (defun eglot-xref-backend () "Eglot xref backend." 'eglot)