From ad1efe5e675216e1f1f342fc9d48018fac718b5e Mon Sep 17 00:00:00 2001 From: =?utf8?q?Jo=C3=A3o=20T=C3=A1vora?= Date: Wed, 29 Mar 2023 19:30:04 +0100 Subject: [PATCH] Eglot: improve caching in eglot-completion-at-point When answering the :textDocument/completion request, LSP servers provide a :isIncomplete flag in the response, which allows Eglot to know if "further typing should result in recomputing [the completions] list. If :isIncomplete is false (i.e. the full set was returned), Eglot caches the response in a global variable eglot--capf-cache that persists for the duration of the "completion session", taken to be the interval between two calls to completion-in-region-mode. If the cache has been set, and Eglot detects that "further typing" has happened, it is safe to use the cache instead of making a request to the server. Thus eglot--capf-cache-flush, added to completion-in-region-mode-hook, is used to flush this cache. Since the popular Company completion package doesn't use completion-in-region-mode, eglot--capf-cache-flush is also added to its company-after-completion-hook. * lisp/progmodes/eglot.el (eglot--managed-mode): Set 'completion-in-region-mode-hook and company-after-completion-hook. (eglot--capf-cache): New variable. (eglot--capf-cache-flush): New function. (eglot-completion-at-point): Rework. * etc/EGLOT-NEWS: Update. --- etc/EGLOT-NEWS | 22 ++++++++ lisp/progmodes/eglot.el | 121 ++++++++++++++++++++++++---------------- 2 files changed, 95 insertions(+), 48 deletions(-) diff --git a/etc/EGLOT-NEWS b/etc/EGLOT-NEWS index dd04e677285..09772a1e71a 100644 --- a/etc/EGLOT-NEWS +++ b/etc/EGLOT-NEWS @@ -17,6 +17,28 @@ This refers to https://github.com/joaotavora/eglot/issues/. That is, to look up issue github#1234, go to https://github.com/joaotavora/eglot/issues/1234. + +* Changes in upcoming Eglot 1.14 + +** Faster, more responsive completion + +Eglot takes advantage of LSP's "isIncomplete" flag in responses to +completion requests to drive new completion-caching mechanism for the +duration of each completion session. Once a full set of completions +is obtained for a given position, the server needn't be contacted in +many scenarios, resulting in significantly less communication +overhead. This works with the popular Company package and stock +completion-at-point interfaces. + +A variable 'eglot-cache-session-completions', t by default, controls +this. The mechanism was tested with ccls, jdtls, pylsp, golsp and +clangd. Notably, the C/C++ language server Clangd version 15 has a +bug in its "isIcomplete" flag (it is fixed in later versions). If you +run into problems, disable this mechanism like so: + +(add-hook 'c-common-mode-hook + (lambda () (setq-local eglot-cache-session-completions nil))) + * Changes in Eglot 1.13 (15/03/2023) diff --git a/lisp/progmodes/eglot.el b/lisp/progmodes/eglot.el index 04fc7235919..7e329d2e26a 100644 --- a/lisp/progmodes/eglot.el +++ b/lisp/progmodes/eglot.el @@ -1863,6 +1863,8 @@ Use `eglot-managed-p' to determine if current buffer is managed.") (unless (eglot--stay-out-of-p 'xref) (add-hook 'xref-backend-functions 'eglot-xref-backend nil t)) (add-hook 'completion-at-point-functions #'eglot-completion-at-point nil t) + (add-hook 'completion-in-region-mode-hook #'eglot--capf-session-flush nil t) + (add-hook 'company-after-completion-hook #'eglot--capf-session-flush nil t) (add-hook 'change-major-mode-hook #'eglot--managed-mode-off nil t) (add-hook 'post-self-insert-hook 'eglot--post-self-insert-hook nil t) (add-hook 'pre-command-hook 'eglot--pre-command-hook nil t) @@ -1894,6 +1896,8 @@ Use `eglot-managed-p' to determine if current buffer is managed.") (remove-hook 'after-save-hook 'eglot--signal-textDocument/didSave t) (remove-hook 'xref-backend-functions 'eglot-xref-backend t) (remove-hook 'completion-at-point-functions #'eglot-completion-at-point t) + (remove-hook 'completion-in-region-mode-hook #'eglot--capf-session-flush t) + (remove-hook 'company-after-completion-hook #'eglot--capf-session-flush t) (remove-hook 'change-major-mode-hook #'eglot--managed-mode-off t) (remove-hook 'post-self-insert-hook 'eglot--post-self-insert-hook t) (remove-hook 'pre-command-hook 'eglot--pre-command-hook t) @@ -2896,6 +2900,13 @@ for which LSP on-type-formatting should be requested." :trimFinalNewlines (if delete-trailing-lines t :json-false)) args))))) +(defvar eglot-cache-session-completions t + "If non-nil Eglot caches data during completion sessions.") + +(defvar eglot--capf-session :none "A cache used by `eglot-completion-at-point'.") + +(defun eglot--capf-session-flush (&optional _) (setq eglot--capf-session :none)) + (defun eglot-completion-at-point () "Eglot's `completion-at-point' function." ;; Commit logs for this function help understand what's going on. @@ -2911,40 +2922,50 @@ for which LSP on-type-formatting should be requested." :sortText))))) (metadata `(metadata (category . eglot) (display-sort-function . ,sort-completions))) - resp items (cached-proxies :none) + (local-cache :none) + (bounds (bounds-of-thing-at-point 'symbol)) + (orig-pos (point)) + (resolved (make-hash-table)) (proxies (lambda () - (if (listp cached-proxies) cached-proxies - (setq resp - (eglot--request server - :textDocument/completion - (eglot--CompletionParams) - :cancel-on-input t)) - (setq items (append - (if (vectorp resp) resp (plist-get resp :items)) - nil)) - (setq cached-proxies - (mapcar - (jsonrpc-lambda - (&rest item &key label insertText insertTextFormat - textEdit &allow-other-keys) - (let ((proxy - ;; Snippet or textEdit, it's safe to - ;; display/insert the label since - ;; it'll be adjusted. If no usable - ;; insertText at all, label is best, - ;; too. - (cond ((or (eql insertTextFormat 2) - textEdit - (null insertText) - (string-empty-p insertText)) - (string-trim-left label)) - (t insertText)))) - (unless (zerop (length proxy)) - (put-text-property 0 1 'eglot--lsp-item item proxy)) - proxy)) - items))))) - (resolved (make-hash-table)) + (if (listp local-cache) local-cache + (let* ((resp (eglot--request server + :textDocument/completion + (eglot--CompletionParams) + :cancel-on-input t)) + (items (append + (if (vectorp resp) resp (plist-get resp :items)) + nil)) + (cachep (and (listp resp) items + eglot-cache-session-completions + (eq (plist-get resp :isIncomplete) :json-false))) + (bounds (or bounds + (cons (point) (point)))) + (proxies + (mapcar + (jsonrpc-lambda + (&rest item &key label insertText insertTextFormat + textEdit &allow-other-keys) + (let ((proxy + ;; Snippet or textEdit, it's safe to + ;; display/insert the label since + ;; it'll be adjusted. If no usable + ;; insertText at all, label is best, + ;; too. + (cond ((or (eql insertTextFormat 2) + textEdit + (null insertText) + (string-empty-p insertText)) + (string-trim-left label)) + (t insertText)))) + (unless (zerop (length proxy)) + (put-text-property 0 1 'eglot--lsp-item item proxy)) + proxy)) + items))) + ;; (trace-values "Requested" (length proxies) cachep bounds) + (setq eglot--capf-session + (if cachep (list bounds proxies resolved orig-pos) :none)) + (setq local-cache proxies))))) (resolve-maybe ;; Maybe completion/resolve JSON object `lsp-comp' into ;; another JSON object, if at all possible. Otherwise, @@ -2957,11 +2978,19 @@ for which LSP on-type-formatting should be requested." (plist-get lsp-comp :data)) (eglot--request server :completionItem/resolve lsp-comp :cancel-on-input t) - lsp-comp))))) - (bounds (bounds-of-thing-at-point 'symbol))) + lsp-comp)))))) + (unless bounds (setq bounds (cons (point) (point)))) + (when (and (consp eglot--capf-session) + (= (car bounds) (car (nth 0 eglot--capf-session))) + (>= (cdr bounds) (cdr (nth 0 eglot--capf-session)))) + (setq local-cache (nth 1 eglot--capf-session) + resolved (nth 2 eglot--capf-session) + orig-pos (nth 3 eglot--capf-session)) + ;; (trace-values "Recalling cache" (length local-cache) bounds orig-pos) + ) (list - (or (car bounds) (point)) - (or (cdr bounds) (point)) + (car bounds) + (cdr bounds) (lambda (probe pred action) (cond ((eq action 'metadata) metadata) ; metadata @@ -3032,7 +3061,7 @@ for which LSP on-type-formatting should be requested." :company-require-match 'never :company-prefix-length (save-excursion - (when (car bounds) (goto-char (car bounds))) + (goto-char (car bounds)) (when (listp completion-capability) (looking-back (regexp-opt @@ -3040,6 +3069,7 @@ for which LSP on-type-formatting should be requested." (eglot--bol)))) :exit-function (lambda (proxy status) + (eglot--capf-session-flush) (when (memq status '(finished exact)) ;; To assist in using this whole `completion-at-point' ;; function inside `completion-in-region', ensure the exit @@ -3063,17 +3093,12 @@ for which LSP on-type-formatting should be requested." (let ((snippet-fn (and (eql insertTextFormat 2) (eglot--snippet-expansion-fn)))) (cond (textEdit - ;; Undo (yes, undo) the newly inserted completion. - ;; If before completion the buffer was "foo.b" and - ;; now is "foo.bar", `proxy' will be "bar". We - ;; want to delete only "ar" (`proxy' minus the - ;; symbol whose bounds we've calculated before) - ;; (github#160). - (delete-region (+ (- (point) (length proxy)) - (if bounds - (- (cdr bounds) (car bounds)) - 0)) - (point)) + ;; Revert buffer back to state when the edit + ;; was obtained from server. If a `proxy' + ;; "bar" was obtained from a buffer with + ;; "foo.b", the LSP edit applies to that' + ;; state, _not_ the current "foo.bar". + (delete-region orig-pos (point)) (eglot--dbind ((TextEdit) range newText) textEdit (pcase-let ((`(,beg . ,end) (eglot--range-region range))) -- 2.39.2