diff --git a/doc/misc/eglot.texi b/doc/misc/eglot.texi index 56151b5482f..38c6adaf131 100644 --- a/doc/misc/eglot.texi +++ b/doc/misc/eglot.texi @@ -502,6 +502,15 @@ project. The command @kbd{M-x eglot-code-actions} will pop up a menu of code applicable actions at point. @end table +@item M-x eglot-inlay-hints-mode +This command toggles LSP ``inlay hints'' on and off for the current +buffer. Inlay hints are small text annotations to specific parts of +the whole buffer, not unlike diagnostics, but designed to help +readability instead of indicating problems. For example, a C++ LSP +server can serve hints about positional parameter names in function +calls and a variable's automatically deduced type. Inlay hints help +the user not have to remember these things by heart. + @end itemize Not all servers support the full set of LSP capabilities, but most of @@ -874,6 +883,14 @@ this map. For example: (define-key eglot-mode-map (kbd "") 'xref-find-definitions) @end lisp +@item eglot-lazy-inlay-hints +This variable controls the operation and performance of LSP Inlay +Hints (@pxref{Eglot Features}). If non-@code{nil}, it specifies how +much time to wait after a window is displayed or scrolled before +requesting hints for that visible portion of a given buffer. If +@code{nil}, inlay hints are always requested for the whole buffer, +even for parts of it not currently visible. + @end vtable Additional variables, which are relevant for customizing the server diff --git a/lisp/progmodes/eglot.el b/lisp/progmodes/eglot.el index 8b0caf41ad7..df755dfa43a 100644 --- a/lisp/progmodes/eglot.el +++ b/lisp/progmodes/eglot.el @@ -47,9 +47,10 @@ ;; definition-chasing, Flymake for diagnostics, Eldoc for at-point ;; documentation, etc. Eglot's job is generally *not* to provide ;; such a UI itself, though a small number of simple -;; counter-examples do exist, for example in the `eglot-rename' -;; command. When a new UI is evidently needed, consider adding a -;; new package to Emacs, or extending an existing one. +;; counter-examples do exist, e.g. in the `eglot-rename' command or +;; the `eglot-inlay-hints-mode' minor mode. When a new UI is +;; evidently needed, consider adding a new package to Emacs, or +;; extending an existing one. ;; ;; * Eglot was designed to function with just the UI facilities found ;; in the latest Emacs core, as long as those facilities are also @@ -483,7 +484,9 @@ This can be useful when using docker to run a language server.") (VersionedTextDocumentIdentifier (:uri :version) ()) (WorkDoneProgress (:kind) (:title :message :percentage :cancellable)) (WorkspaceEdit () (:changes :documentChanges)) - (WorkspaceSymbol (:name :kind) (:containerName :location :data))) + (WorkspaceSymbol (:name :kind) (:containerName :location :data)) + (InlayHint (:position :label) (:kind :textEdits :tooltip :paddingLeft + :paddingRight :data))) "Alist (INTERFACE-NAME . INTERFACE) of known external LSP interfaces. INTERFACE-NAME is a symbol designated by the spec as @@ -803,6 +806,7 @@ treated as in `eglot--dbind'." :formatting `(:dynamicRegistration :json-false) :rangeFormatting `(:dynamicRegistration :json-false) :rename `(:dynamicRegistration :json-false) + :inlayHint `(:dynamicRegistration :json-false) :publishDiagnostics (list :relatedInformation :json-false ;; TODO: We can support :codeDescription after ;; adding an appropriate UI to @@ -1625,7 +1629,8 @@ under cursor." (const :tag "Highlight links in document" :documentLinkProvider) (const :tag "Decorate color references" :colorProvider) (const :tag "Fold regions of buffer" :foldingRangeProvider) - (const :tag "Execute custom commands" :executeCommandProvider))) + (const :tag "Execute custom commands" :executeCommandProvider) + (const :tag "Inlay hints" :inlayHintProvider))) (defun eglot--server-capable (&rest feats) "Determine if current server is capable of FEATS." @@ -1818,6 +1823,7 @@ Use `eglot-managed-p' to determine if current buffer is managed.") (defun eglot--managed-mode-off () "Turn off `eglot--managed-mode' unconditionally." + (remove-overlays nil nil 'eglot--overlay t) (eglot--managed-mode -1)) (defun eglot-current-server () @@ -2285,6 +2291,7 @@ THINGS are either registrations or unregisterations (sic)." (defun eglot--before-change (beg end) "Hook onto `before-change-functions' with BEG and END." + (remove-overlays beg end 'eglot--overlay t) (when (listp eglot--recent-changes) ;; Records BEG and END, crucially convert them into LSP ;; (line/char) positions before that information is lost (because @@ -2297,6 +2304,9 @@ THINGS are either registrations or unregisterations (sic)." (,end . ,(copy-marker end t))) eglot--recent-changes))) +(defvar eglot--document-changed-hook '(eglot--signal-textDocument/didChange) + "Internal hook for doing things when the document changes.") + (defun eglot--after-change (beg end pre-change-length) "Hook onto `after-change-functions'. Records BEG, END and PRE-CHANGE-LENGTH locally." @@ -2337,7 +2347,7 @@ Records BEG, END and PRE-CHANGE-LENGTH locally." eglot-send-changes-idle-time nil (lambda () (eglot--when-live-buffer buf (when eglot--managed-mode - (eglot--signal-textDocument/didChange) + (run-hooks 'eglot--document-changed-hook) (setq eglot--change-idle-timer nil)))))))) ;; HACK! Launching a deferred sync request with outstanding changes is a @@ -3464,6 +3474,129 @@ If NOERROR, return predicate, else erroring function." (revert-buffer) (pop-to-buffer (current-buffer))))) + +;;; Inlay hints +(defface eglot-inlay-hint-face '((t (:height 0.8 :inherit shadow))) + "Face used for inlay hint overlays.") + +(defface eglot-type-hint-face '((t (:inherit eglot-inlay-hint-face))) + "Face used for type inlay hint overlays.") + +(defface eglot-parameter-hint-face '((t (:inherit eglot-inlay-hint-face))) + "Face used for parameter inlay hint overlays.") + +(defcustom eglot-lazy-inlay-hints 0.3 + "If non-nil, restrict LSP inlay hints to visible portion of buffer. + +Value is number specifying how many seconds to wait after a +window has been (re)scrolled before requesting new inlay hints +for the visible region of the window being manipulated. + +If nil, then inlay hints are requested for the entire buffer. + +This value is only meaningful if the minor mode +`eglot-inlay-hints-mode' is true. +" + :type 'number + :version "29.1") + +(defun eglot--inlay-hints-fully () + (eglot--widening (eglot--update-hints-1 (point-min) (point-max)))) + +(cl-defun eglot--inlay-hints-lazily (&optional (buffer (current-buffer))) + (eglot--when-live-buffer buffer + (when eglot--managed-mode + (dolist (window (get-buffer-window-list nil nil 'visible)) + (eglot--update-hints-1 (window-start window) (window-end window)))))) + +(defun eglot--update-hints-1 (from to) + "Request LSP inlay hints and annotate current buffer from FROM to TO." + (let* ((buf (current-buffer)) + (paint-hint + (eglot--lambda ((InlayHint) position kind label paddingLeft paddingRight) + (goto-char (eglot--lsp-position-to-point position)) + (let ((ov (make-overlay (point) (point))) + (left-pad (and paddingLeft (not (memq (char-before) '(32 9))))) + (right-pad (and paddingRight (not (memq (char-after) '(32 9))))) + (text (if (stringp label) label (plist-get label :value)))) + (overlay-put ov 'before-string + (propertize + (concat (and left-pad " ") text (and right-pad " ")) + 'face (pcase kind + (1 'eglot-type-hint-face) + (2 'eglot-parameter-hint-face) + (_ 'eglot-inlay-hint-face)))) + (overlay-put ov 'eglot--inlay-hint t) + (overlay-put ov 'eglot--overlay t))))) + (jsonrpc-async-request + (eglot--current-server-or-lose) + :textDocument/inlayHint + (list :textDocument (eglot--TextDocumentIdentifier) + :range (list :start (eglot--pos-to-lsp-position from) + :end (eglot--pos-to-lsp-position to))) + :success-fn (lambda (hints) + (eglot--when-live-buffer buf + (eglot--widening + (remove-overlays from to 'eglot--inlay-hint t) + (mapc paint-hint hints)))) + :deferred 'eglot--update-hints-1))) + +(defun eglot--inlay-hints-after-scroll (window display-start) + (cl-macrolet ((wsetq (sym val) `(set-window-parameter window ',sym ,val)) + (wgetq (sym) `(window-parameter window ',sym))) + (let ((buf (window-buffer window)) + (timer (wgetq eglot--inlay-hints-timer)) + (last-display-start (wgetq eglot--last-inlay-hint-display-start))) + (when (and eglot-lazy-inlay-hints + ;; FIXME: If `window' is _not_ the selected window, + ;; then for some unknown reason probably related to + ;; the overlays added later to the buffer, the scroll + ;; function will be called indefinitely. Not sure if + ;; an Emacs bug, but prevent useless duplicate calls + ;; by saving and examining `display-start' fixes it. + (not (eql last-display-start display-start))) + (when timer (cancel-timer timer)) + (wsetq eglot--last-inlay-hint-display-start + display-start) + (wsetq eglot--inlay-hints-timer + (run-at-time + eglot-lazy-inlay-hints + nil (lambda () + (eglot--when-live-buffer buf + (when (eq buf (window-buffer window)) + (eglot--update-hints-1 (window-start window) + (window-end window)) + (wsetq eglot--inlay-hints-timer nil)))))))))) + +(define-minor-mode eglot-inlay-hints-mode + "Minor mode annotating buffer with LSP inlay hints." + :global nil + (cond (eglot-inlay-hints-mode + (cond + ((not (eglot--server-capable :inlayHintProvider)) + (eglot--warn + "No :inlayHintProvider support. Inlay hints will not work.")) + (eglot-lazy-inlay-hints + (add-hook 'eglot--document-changed-hook + #'eglot--inlay-hints-lazily t t) + (add-hook 'window-scroll-functions + #'eglot--inlay-hints-after-scroll nil t) + ;; Maybe there isn't a window yet for current buffer, + ;; so `run-at-time' ensures this runs after redisplay. + (run-at-time 0 nil #'eglot--inlay-hints-lazily)) + (t + (add-hook 'eglot--document-changed-hook + #'eglot--inlay-hints-fully nil t) + (eglot--inlay-hints-fully)))) + (t + (remove-hook 'eglot--document-changed-hook + #'eglot--inlay-hints-lazily t) + (remove-hook 'eglot--document-changed-hook + #'eglot--inlay-hints-fully t) + (remove-hook 'window-scroll-functions + #'eglot--inlay-hints-after-scroll t) + (remove-overlays nil nil 'eglot--inlay-hint t)))) + ;;; Hacks ;;;