GNU bug report logs - #79374
[PATCH] eglot: add support for semantic tokens

Previous Next

Package: emacs;

Reported by: me <at> lua.blog.br

Date: Wed, 3 Sep 2025 00:43:02 UTC

Severity: normal

Tags: patch

Full log


View this message in rfc822 format

From: Eli Zaretskii <eliz <at> gnu.org>
To: me <at> lua.blog.br, joaotavora <at> gmail.com
Cc: philipk <at> posteo.net, 79374 <at> debbugs.gnu.org
Subject: bug#79374: [PATCH] eglot: add support for semantic tokens
Date: Sat, 13 Sep 2025 10:57:35 +0300
João, any comments on the patch?  Should I install it?

> Cc: 79374 <at> debbugs.gnu.org,
>  João Távora <joaotavora <at> gmail.com>
> From: "Lua V. R." <me <at> lua.blog.br>
> Date: Thu,  4 Sep 2025 00:02:51 +0000 (UTC)
> 
> Hi Philip,
> 
> Em 03/09/2025 05:01, Philip Kaludercic escreveu:
> > As mentioned on Emacs-devel, shouldn't this be `eglot-semantic-tokens-mode'.
> Thanks for catching this :) I fixed it among other things, and I 
> apologize for being unfamiliar with the e-mail git workflow. I don't 
> know if I should send a patch on top of the previous patch, but I'm 
> sending a squashed one.
> 
> In this new patch I also removed a "hash" I used in attempt to avoid a 
> burst of requests, because it was not working in some particular 
> situations (I can explain if you want). Now, due to how jit-lock works, 
> Eglot may send two requests after a didChange if the server does not 
> support delta requests. But this was also the case in the version with 
> the hash, so alas, it was just complicating things.
> 
> From 32471c4a0fc9ca80ae8d3c299a3bd55581f8cb04 Mon Sep 17 00:00:00 2001
> From: Lua Viana Reis <me <at> lua.blog.br>
> Date: Tue, 2 Sep 2025 19:03:52 -0300
> Subject: [PATCH] add semantic tokens support for eglot
> 
> ---
>  lisp/progmodes/eglot.el | 316 +++++++++++++++++++++++++++++++++++++++-
>  1 file changed, 313 insertions(+), 3 deletions(-)
> 
> diff --git a/lisp/progmodes/eglot.el b/lisp/progmodes/eglot.el
> index 475b5e13f1..1d9fb7905d 100644
> --- a/lisp/progmodes/eglot.el
> +++ b/lisp/progmodes/eglot.el
> @@ -141,6 +141,8 @@
>  (defvar company-tooltip-align-annotations)
>  (defvar tramp-ssh-controlmaster-options)
>  (defvar tramp-use-ssh-controlmaster-options)
> +(defvar eglot-semantic-tokens-faces)
> +(defvar eglot-semantic-tokens-modifier-faces)
>  
>  
>  ;;; Obsolete aliases
> @@ -1017,6 +1019,7 @@ object."
>                          `(:dynamicRegistration
>                            ,(if (eglot--trampish-p s) :json-false t))
>                          :symbol `(:dynamicRegistration :json-false)
> +                        :semanticTokens '(:refreshSupport t)
>                          :configuration t
>                          :workspaceFolders t)
>              :textDocument
> @@ -1079,6 +1082,13 @@ object."
>               :formatting         `(:dynamicRegistration :json-false)
>               :rangeFormatting    `(:dynamicRegistration :json-false)
>               :rename             `(:dynamicRegistration :json-false)
> +             :semanticTokens     `(:dynamicRegistration :json-false
> +                                   :requests '(:range t :full (:delta t))
> +                                   :tokenModifiers [,@(mapcar #'car eglot-semantic-tokens-modifier-faces)]
> +                                   :overlappingTokenSupport t
> +                                   :multilineTokenSupport t
> +                                   :tokenTypes [,@(mapcar #'car eglot-semantic-tokens-faces)]
> +                                   :formats ["relative"])
>               :inlayHint          `(:dynamicRegistration :json-false)
>               :callHierarchy      `(:dynamicRegistration :json-false)
>               :typeHierarchy      `(:dynamicRegistration :json-false)
> @@ -1149,7 +1159,16 @@ object."
>      :accessor eglot--managed-buffers)
>     (saved-initargs
>      :documentation "Saved initargs for reconnection purposes."
> -    :accessor eglot--saved-initargs))
> +    :accessor eglot--saved-initargs)
> +   (semtok-faces
> +    :initform nil
> +    :documentation "Semantic tokens faces.")
> +   (semtok-modifier-faces
> +    :initform nil
> +    :documentation "Semantic tokens modifier faces.")
> +   (semtok-modifier-cache
> +    :initform (make-hash-table)
> +    :documentation "A hashmap of modifier values to the selected faces."))
>    :documentation
>    "Represents a server. Wraps a process for LSP communication.")
>  
> @@ -1211,6 +1230,11 @@ If optional MARKERS, make markers instead."
>           (end (eglot--lsp-position-to-point (plist-get range :end) markers)))
>      (cons beg end)))
>  
> +(defun eglot-region-range (beg end)
> +  "Return a LSP range representing region BEG to END."
> +  (list :start (eglot--pos-to-lsp-position beg)
> +        :end (eglot--pos-to-lsp-position end)))
> +
>  (defun eglot-server-capable (&rest feats)
>    "Determine if current server is capable of FEATS."
>    (unless (cl-some (lambda (feat)
> @@ -1576,7 +1600,8 @@ Use current server's or first available Eglot events buffer."
>    (jsonrpc-forget-pending-continuations server))
>  
>  (defvar eglot-connect-hook
> -  '(eglot-signal-didChangeConfiguration)
> +  '(eglot-signal-didChangeConfiguration
> +    eglot--semtok-initialize)
>    "Hook run after connecting to a server.
>  Each function is passed an `eglot-lsp-server' instance
>  as argument.")
> @@ -2291,6 +2316,7 @@ If it is activated, also signal textDocument/didOpen."
>        ;; Run user hook after 'textDocument/didOpen' so server knows
>        ;; about the buffer.
>        (eglot-inlay-hints-mode 1)
> +      (eglot-semantic-tokens-mode 1)
>        (run-hooks 'eglot-managed-mode-hook))))
>  
>  (add-hook 'after-change-major-mode-hook #'eglot--maybe-activate-editing-mode)
> @@ -4494,6 +4520,288 @@ If NOERROR, return predicate, else erroring function."
>           (jit-lock-unregister #'eglot--update-hints)
>           (remove-overlays nil nil 'eglot--inlay-hint t))))
>  
> +
> +;;; Semantic tokens
> +
> +(defcustom eglot-semantic-tokens-faces
> +  '(("namespace" . font-lock-keyword-face)
> +    ("type" . font-lock-type-face)
> +    ("class" . font-lock-type-face)
> +    ("enum" . font-lock-type-face)
> +    ("interface" . font-lock-type-face)
> +    ("struct" . font-lock-type-face)
> +    ("typeParameter" . font-lock-type-face)
> +    ("parameter" . font-lock-variable-name-face)
> +    ("variable" . font-lock-variable-name-face)
> +    ("property" . font-lock-property-use-face)
> +    ("enumMember" . font-lock-constant-face)
> +    ("event" . font-lock-variable-name-face)
> +    ("function" . font-lock-function-name-face)
> +    ("method" . font-lock-function-name-face)
> +    ("macro" . font-lock-preprocessor-face)
> +    ("keyword" . font-lock-keyword-face)
> +    ("modifier" . font-lock-function-name-face)
> +    ("comment" . font-lock-comment-face)
> +    ("string" . font-lock-string-face)
> +    ("number" . font-lock-constant-face)
> +    ("regexp" . font-lock-string-face)
> +    ("operator" . font-lock-function-name-face)
> +    ("decorator" . font-lock-type-face))
> +  "Alist of faces to use to highlight semantic tokens.
> +Each element is a cons cell whose car is a token type name and cdr is
> +the face to use."
> +  :type `(alist :key-type (string :tag "Token name")
> +                :value-type (choice (face :tag "Face")
> +                                    (plist :tag "Face Attributes"
> +                                           :key-type
> +                                           (choice
> +                                            ,@(mapcar
> +                                               (lambda (cell)
> +                                                 `(const :tag ,(capitalize
> +                                                                (cdr cell))
> +                                                         ,(car cell)))
> +                                               face-attribute-name-alist))))))
> +
> +(defcustom eglot-semantic-tokens-modifier-faces
> +  '(("declaration" . font-lock-function-name-face)
> +    ("definition" . font-lock-function-name-face)
> +    ("readonly" . font-lock-constant-face)
> +    ("static" . font-lock-keyword-face)
> +    ("deprecated" . eglot-diagnostic-tag-deprecated-face)
> +    ("abstract" . font-lock-keyword-face)
> +    ("async" . font-lock-preprocessor-face)
> +    ("modification" . font-lock-function-name-face)
> +    ("documentation" . font-lock-doc-face)
> +    ("defaultLibrary" . font-lock-builtin-face))
> +  "List of face to use to highlight tokens with modifiers.
> +Each element is a cons cell whose car is a modifier name and cdr is
> +the face to use."
> +  :type `(alist :key-type (string :tag "Token name")
> +                :value-type (choice (face :tag "Face")
> +                                    (plist :tag "Face Attributes"
> +                                           :key-type
> +                                           (choice
> +                                            ,@(mapcar
> +                                               (lambda (cell)
> +                                                 `(const :tag ,(capitalize
> +                                                                (cdr cell))
> +                                                         ,(car cell)))
> +                                               face-attribute-name-alist))))))
> +
> +(defvar-local eglot--semtok-idle-timer nil
> +  "Idle timer to request full semantic tokens.")
> +
> +(defvar-local eglot--semtok-cache nil)
> +
> +(defsubst eglot--semtok-put-cache (k v)
> +  "Set key K of `eglot-semantic-tokens--cache' to V."
> +  (setq eglot--semtok-cache
> +        (plist-put eglot--semtok-cache k v)))
> +
> +;; Process response
> +
> +(defun eglot--semtok-ingest-range-response (response)
> +  "Handle RESPONSE to semanticTokens/range request."
> +  (eglot--semtok-put-cache :response response)
> +  (cl-assert (plist-get eglot--semtok-cache :region)))
> +
> +(defun eglot--semtok-ingest-full-response (response)
> +  "Handle RESPONSE to semanticTokens/full request."
> +  (eglot--semtok-put-cache :response response)
> +  (cl-assert (not (plist-get eglot--semtok-cache :region))))
> +
> +(defsubst eglot--semtok-apply-delta-edits (old-data edits)
> +  "Apply EDITS obtained from full/delta request to OLD-DATA."
> +  (let* ((old-token-count (length old-data))
> +         (old-token-index 0)
> +         (substrings))
> +    (cl-loop for edit across edits do
> +     (when (< old-token-index (plist-get edit :start))
> +       (push (substring old-data old-token-index (plist-get edit :start)) substrings))
> +     (push (plist-get edit :data) substrings)
> +     (setq old-token-index (+ (plist-get edit :start) (plist-get edit :deleteCount)))
> +     finally do (push (substring old-data old-token-index old-token-count) substrings))
> +    (apply #'vconcat (nreverse substrings))))
> +
> +(defun eglot--semtok-ingest-full/delta-response (response)
> +  "Handle RESPONSE to semanticTokens/full/delta request."
> +  (if-let* ((edits (plist-get response :edits)))
> +      (progn
> +        (cl-assert (not (plist-get eglot--semtok-cache :region)))
> +        (when-let* ((old-data (plist-get (plist-get eglot--semtok-cache :response) :data)))
> +          (eglot--semtok-put-cache
> +           :response
> +           (plist-put response :data (eglot--semtok-apply-delta-edits old-data edits)))))
> +    ;; server decided to send full response instead
> +    (eglot--semtok-ingest-full-response response)))
> +
> +(defun eglot--semtok-request (region &optional fontify)
> +  "Send semantic tokens request to the language server.
> +A full/delta request will be sent if delta requests are supported by the
> +language server and a full set of tokens had previously been received.
> +Otherwise, a ranged request will be dispatched if REGION is non-nil and
> +ranged requests are supported by the language server. In all other
> +cases, a full tokens request will be dispatched.
> +
> +If FONTIFY is non-nil, refontify after the request completes."
> +  (let* ((method :textDocument/semanticTokens/full)
> +         (params (list :textDocument (eglot--TextDocumentIdentifier)))
> +         (response-handler #'eglot--semtok-ingest-full-response)
> +         (final-region nil)
> +         (buf (current-buffer)))
> +    (cond
> +     ((and (eglot-server-capable :semanticTokensProvider :full :delta)
> +           (let ((response (plist-get eglot--semtok-cache :response)))
> +             (and (plist-get response :resultId) (plist-get response :data)
> +                  (not (plist-get eglot--semtok-cache :region)))))
> +      (setq method :textDocument/semanticTokens/full/delta)
> +      (setq response-handler #'eglot--semtok-ingest-full/delta-response)
> +      (setq params
> +            (plist-put params :previousResultId
> +                       (plist-get (plist-get eglot--semtok-cache :response) :resultId))))
> +     ((and region (eglot-server-capable :semanticTokensProvider :range))
> +      (setq method :textDocument/semanticTokens/range)
> +      (setq final-region region)
> +      (setq params
> +            (plist-put params :range (eglot-region-range
> +                                      (car final-region) (cdr final-region))))
> +      (setq response-handler #'eglot--semtok-ingest-range-response)))
> +    (eglot--async-request
> +     (eglot--current-server-or-lose) method params
> +     :success-fn
> +     (lambda (response)
> +       (eglot--when-live-buffer buf
> +         (eglot--semtok-put-cache :documentVersion eglot--versioned-identifier)
> +         (eglot--semtok-put-cache :region final-region)
> +         (funcall response-handler response)
> +         (when fontify (jit-lock-refontify (car-safe region) (cdr-safe region)))
> +         (when final-region (eglot--semtok-request-full-on-idle))))
> +     :hint #'eglot--semtok-request)))
> +
> +(defun eglot--semtok-fontify (beg end)
> +  "Apply the cached semantic tokens from BEG to END."
> +  (with-slots ((modifier-cache semtok-modifier-cache)
> +               (faces semtok-faces)
> +               (modifier-faces semtok-modifier-faces))
> +      (eglot-current-server)
> +    (cond
> +     ((not (and faces
> +                eglot--semtok-cache
> +                (plist-get eglot--semtok-cache :response)
> +                (eq eglot--versioned-identifier (plist-get eglot--semtok-cache :documentVersion))))
> +      (eglot--semtok-request (cons beg end) t))
> +     (t
> +      ;; if we're using the response to a ranged request, we'll only be able to fontify within
> +      ;; that range (and hence shouldn't clear any highlights outside of that range)
> +      (when-let* ((token-region (plist-get eglot--semtok-cache :region)))
> +        (progn
> +          (when (or (< beg (car token-region))
> +                    (> end (cdr token-region)))
> +            ;; truncated! continue, but request again
> +            (eglot--semtok-request (cons beg end) t))
> +          (setq beg (max beg (car token-region)))
> +          (setq end (min end (cdr token-region)))))
> +      (eglot--widening
> +        (with-silent-modifications
> +          (remove-list-of-text-properties beg end '(font-lock-face))
> +          (let* ((inhibit-field-text-motion t)
> +                 (data (plist-get (plist-get eglot--semtok-cache :response) :data))
> +                 (i-max (length data))
> +                 (beg-bol (and (goto-char beg) (line-beginning-position)))
> +                 (property-beg)
> +                 (property-end))
> +            (goto-char (point-min))
> +            (cl-do ((i 0 (+ i 5)) (column 0)) ((>= i i-max))
> +              (when (> (aref data i) 0)
> +                (setq column 0)
> +                (forward-line (aref data i)))
> +              (unless (< (point) beg-bol)
> +                (setq column (+ column (aref data (+ i 1))))
> +                (funcall eglot-move-to-linepos-function column)
> +                (when (> (point) end) (cl-return))
> +                (setq property-beg (point))
> +                (funcall eglot-move-to-linepos-function (+ column (aref data (+ i 2))))
> +                (setq property-end (point))
> +                (when-let* ((face (aref faces (aref data (+ i 3)))))
> +                  (put-text-property property-beg property-end 'font-lock-face face))
> +                (let* ((code (aref data (+ i 4)))
> +                       (faces (gethash code modifier-cache 'not-found)))
> +                  (when (eq faces 'not-found)
> +                    (setq faces nil)
> +                    (cl-loop for j from 0 below (length modifier-faces)
> +                             if (> (logand code (ash 1 j)) 0)
> +                             if (aref modifier-faces j)
> +                             collect (aref modifier-faces j) into faces)
> +                    (puthash code faces modifier-cache))
> +                  (dolist (face faces)
> +                    (put-text-property property-beg property-end 'font-lock-face face))))))))
> +      `(jit-lock-bounds ,beg . ,end)))))
> +
> +(defun eglot--semtok-request-full-on-idle ()
> +  "Make a full semantic tokens request after an idle timer."
> +  (let* ((buf (current-buffer))
> +         (fun (lambda ()
> +                (eglot--when-live-buffer buf
> +                  (eglot--semtok-request nil)))))
> +    (when eglot--semtok-idle-timer (cancel-timer eglot--semtok-idle-timer))
> +    (setq eglot--semtok-idle-timer (run-with-idle-timer (* 3 eglot-send-changes-idle-time) nil fun))))
> +
> +(defun eglot--semtok-on-refresh (server)
> +  "Clear semantic tokens within all buffers of SERVER."
> +  (cl-loop for buffer in (eglot--managed-buffers server) do
> +           (with-current-buffer buffer
> +             (setq eglot--semtok-cache nil)
> +             (jit-lock-refontify))))
> +
> +(let ((debounce-timer nil))
> +  (cl-defmethod eglot-handle-request
> +    (server (_method (eql workspace/semanticTokens/refresh)))
> +    "Handle a semanticTokens/refresh request from SERVER."
> +    (when debounce-timer (cancel-timer debounce-timer))
> +    (setq debounce-timer (run-with-timer 5 nil #'eglot--semtok-on-refresh server))
> +    nil))
> +
> +;; Initialization
> +(defun eglot--semtok-build-face-map (identifiers faces category varname)
> +  "Build map of FACES for IDENTIFIERS using CATEGORY and VARNAME."
> +  (vconcat
> +   (mapcar (lambda (id)
> +             (let ((maybe-face (cdr (assoc id faces))))
> +               (when (not maybe-face)
> +                 (display-warning
> +                  'eglot-semtok
> +                  (format-message "No face has been associated to the %s `%s': consider adding a corresponding definition to %s"
> +                                  category id varname)))
> +               maybe-face))
> +           identifiers)))
> +
> +(defun eglot--semtok-initialize (server)
> +  "Initialize SERVER for semantic tokens."
> +  (cl-destructuring-bind (&key tokenTypes tokenModifiers &allow-other-keys)
> +      (plist-get (plist-get (eglot--capabilities server)
> +                            :semanticTokensProvider)
> +                 :legend)
> +    (oset server semtok-faces
> +          (eglot--semtok-build-face-map
> +           tokenTypes eglot-semantic-tokens-faces
> +           "semantic token" "eglot--semtokfaces"))
> +    (oset server semtok-modifier-faces
> +          (eglot--semtok-build-face-map
> +           tokenModifiers eglot-semantic-tokens-modifier-faces
> +           "semantic token modifier" "eglot--semtokmodifier-faces"))))
> +
> +(define-minor-mode eglot-semantic-tokens-mode
> +  "Minor mode for fontifying buffer with LSP server's semantic tokens."
> +  :global nil
> +  (cond (eglot-semantic-tokens-mode
> +         (if (eglot-server-capable :semanticTokensProvider)
> +             (jit-lock-register #'eglot--semtok-fontify 'contextual)
> +           (eglot-semantic-tokens-mode -1)))
> +        (t
> +         (jit-lock-unregister #'eglot--semtok-fontify)
> +         (with-silent-modifications
> +           (remove-list-of-text-properties (point-min) (point-max) '(font-lock-face))))))
> +
>  
>  ;;; Call and type hierarchies
>  (require 'button)
> @@ -4728,7 +5036,8 @@ If NOERROR, return predicate, else erroring function."
>  ;; harder. For now, use `with-eval-after-load'. See also github#1183.
>  (with-eval-after-load 'desktop
>    (add-to-list 'desktop-minor-mode-handlers '(eglot--managed-mode . ignore))
> -  (add-to-list 'desktop-minor-mode-handlers '(eglot-inlay-hints-mode . ignore)))
> +  (add-to-list 'desktop-minor-mode-handlers '(eglot-inlay-hints-mode . ignore))
> +  (add-to-list 'desktop-minor-mode-handlers '(eglot-semantic-tokens-mode . ignore)))
>  
>  
>  ;;; Misc
> @@ -4757,6 +5066,7 @@ If NOERROR, return predicate, else erroring function."
>                 eglot-format
>                 eglot-format-buffer
>                 eglot-inlay-hints-mode
> +               eglot-semantic-tokens-mode
>                 eglot-reconnect
>                 eglot-rename
>                 eglot-signal-didChangeConfiguration
> -- 
> 2.51.0
> 




This bug report was last modified today.

Previous Next


GNU bug tracking system
Copyright (C) 1999 Darren O. Benham, 1997,2003 nCipher Corporation Ltd, 1994-97 Ian Jackson.