11; ;; eglot-tests.el --- Tests for eglot.el -*- lexical-binding : t ; -*-
22
3- ; ; Copyright (C) 2018-2025 Free Software Foundation, Inc.
3+ ; ; Copyright (C) 2018-2026 Free Software Foundation, Inc.
44
55; ; Author: João Távora <joaotavora@gmail.com>
66; ; Keywords: tests
@@ -185,7 +185,9 @@ directory hierarchy."
185185 (funcall fn)))
186186 (cancel-timer timer)
187187 (when (eq retval timed-out)
188- (error " %s " (concat " Timed out " message))))))
188+ (if (getenv " EMACS_EMBA_CI" )
189+ (ert-skip (concat " Timed out " message))
190+ (error " %s " (concat " Timed out " message)))))))
189191
190192(defun eglot--find-file-noselect (file &optional noerror )
191193 (unless (or noerror
@@ -236,39 +238,47 @@ directory hierarchy."
236238 ,@body )
237239 (remove-hook 'jsonrpc-event-hook #' , log-event-hook-sym ))))))
238240
239- (cl-defmacro eglot--wait-for ((events-sym &optional (timeout 1 ) message) args &body body )
241+ (cl-defmacro eglot--wait-for ((events-sym &optional (timeout 1 ) message)
242+ args &body body )
240243 (declare (indent 2 ) (debug (sexp sexp sexp &rest form)))
241- `(eglot--with-timeout '(, timeout ,(or message
242- (format " waiting for:\n %s " (pp-to-string body))))
244+ `(eglot--with-timeout '(, timeout
245+ ,(or message
246+ (format " waiting for:\n %s " (pp-to-string body))))
243247 (eglot--test-message " waiting for `%s' " (with-output-to-string
244248 (mapc #'princ ', body )))
245- (let ((events
246- (cl-loop thereis (cl-loop for json in , events-sym
247- for method = (plist-get json :method )
248- when (keywordp method)
249- do (plist-put json :method
250- (substring
251- (symbol-name method)
252- 1 ))
253- when (funcall
254- (jsonrpc-lambda , args ,@body ) json)
255- return (cons json before)
256- collect json into before)
257- for i from 0
258- when (zerop (mod i 5 ))
259- ; ; do (eglot--test-message "still struggling to find in %s"
260- ; ; ,events-sym)
261- do
262- ; ; `read-event' is essential to have the file
263- ; ; watchers come through.
264- (cond ((fboundp 'flush-standard-output )
265- (read-event nil nil 0.1 ) (princ " ." )
266- (flush-standard-output))
267- (t
268- (read-event " ." nil 0.1 )))
269- (accept-process-output nil 0.1 ))))
270- (setq , events-sym (cdr events))
271- (cl-destructuring-bind (&key method id &allow-other-keys) (car events)
249+ (let ((probe
250+ (cl-loop
251+ thereis
252+ (cl-loop for (json . tail) on , events-sym
253+ for method = (plist-get json :method )
254+ when (keywordp method)
255+ do (plist-put
256+ json :method (substring (symbol-name method) 1 ))
257+ when (funcall (jsonrpc-lambda , args ,@body ) json)
258+ return json
259+ do
260+ (unless
261+ ; ; $/progress is *truly* uninteresting and spammy
262+ (and (string-match " $/progress" (format " %s " method)))
263+ (eglot--test-message
264+ " skip uninteresting event %s[%s]"
265+ (plist-get json :method )
266+ (plist-get json :id )))
267+ finally (setq , events-sym tail))
268+ for i from 0
269+ when (zerop (mod i 5 ))
270+ ; ; do (eglot--test-message "still struggling to find in %s"
271+ ; ; ,events-sym)
272+ do
273+ ; ; `read-event' is essential to have the file
274+ ; ; watchers come through.
275+ (cond ((fboundp 'flush-standard-output )
276+ (read-event nil nil 0.1 ) (princ " ." )
277+ (flush-standard-output))
278+ (t
279+ (read-event " ." nil 0.1 )))
280+ (accept-process-output nil 0.1 ))))
281+ (cl-destructuring-bind (&key method id &allow-other-keys) probe
272282 (eglot--test-message " detected: %s"
273283 (or method (and id (format " id=%s " id))))))))
274284
@@ -284,10 +294,13 @@ directory hierarchy."
284294 (define-derived-mode typescript-mode prog-mode " TypeScript" )
285295 (add-to-list 'auto-mode-alist '(" \\ .ts\\ '" . typescript-mode)))
286296
287- (defun eglot--tests-connect (&optional timeout )
297+ (cl- defun eglot--tests-connect (&key timeout server )
288298 (let* ((timeout (or timeout 10 ))
289299 (eglot-sync-connect t )
290- (eglot-connect-timeout timeout))
300+ (eglot-connect-timeout timeout)
301+ (eglot-server-programs
302+ (if server `((, major-mode . ,(string-split server)))
303+ eglot-server-programs)))
291304 (apply #'eglot--connect (eglot--guess-contact))))
292305
293306(defun eglot--simulate-key-event (char )
@@ -315,7 +328,7 @@ directory hierarchy."
315328 (with-current-buffer
316329 (eglot--find-file-noselect " project/src/main/java/foo/Main.java" )
317330 (eglot--sniffing (:server-notifications s-notifs)
318- (should (eglot--tests-connect 20 ))
331+ (should (eglot--tests-connect :timeout 20 ))
319332 (eglot--wait-for (s-notifs 10 )
320333 (&key _id method &allow-other-keys)
321334 (string= method " language/status" ))))))
@@ -429,15 +442,69 @@ directory hierarchy."
429442 (with-current-buffer
430443 (eglot--find-file-noselect " diag-project/main.c" )
431444 (eglot--sniffing (:server-notifications s-notifs)
432- (eglot--tests-connect)
445+ (eglot--tests-connect :server " clangd" )
446+ (flymake-start)
433447 (eglot--wait-for (s-notifs 10 )
434- (&key _id method &allow-other-keys)
448+ (&key method &allow-other-keys)
435449 (string= method " textDocument/publishDiagnostics" ))
450+ (goto-char (point-min ))
451+ (flymake-goto-next-error 1 '() t )
452+ (should (eq 'flymake-error (face-at-point )))))))
453+
454+ (ert-deftest eglot-test-basic-pull-diagnostics ()
455+ " Test basic diagnostics."
456+ (skip-unless (executable-find " ty" ))
457+ (eglot--with-fixture
458+ `((" diag-project" .
459+ ((" main.py" . " def main:\n puss" ))))
460+ (with-current-buffer
461+ (eglot--find-file-noselect " diag-project/main.py" )
462+ (eglot--sniffing (:server-replies s-replies)
463+ (eglot--tests-connect :server " ty server" )
436464 (flymake-start)
465+ (eglot--wait-for (s-replies 5 )
466+ (&key _id method &allow-other-keys)
467+ (string= method " textDocument/diagnostic" ))
437468 (goto-char (point-min ))
438469 (flymake-goto-next-error 1 '() t )
439470 (should (eq 'flymake-error (face-at-point )))))))
440471
472+ (ert-deftest eglot-test-basic-stream-diagnostics ()
473+ " Test basic diagnostics."
474+ (skip-unless (executable-find " rass" ))
475+ (skip-unless (executable-find " ruff" ))
476+ (skip-unless (executable-find " ty" ))
477+ (eglot--with-fixture
478+ `((" diag-project" .
479+ ((" main.py" . " from lib import greet\n def main():\n greet()" )
480+ (" lib.py" . " def geet():\n print('hello')" ))))
481+ (set-buffer (eglot--find-file-noselect " diag-project/main.py" ))
482+ (eglot--sniffing (:server-notifications s-notifs)
483+ (eglot--tests-connect :server " rass -- ty server -- ruff server" )
484+ (flymake-start)
485+ (cl-loop repeat 2 ; ; 2 stream notifs for 2 rass servers
486+ do (eglot--wait-for (s-notifs 5 )
487+ (&key method &allow-other-keys)
488+ (string= method " $/streamDiagnostics" )))
489+ (goto-char (point-min ))
490+ (flymake-goto-next-error 1 '() t )
491+ (should (eq 'flymake-error (face-at-point ))))
492+
493+ ; ; Now fix it
494+ (set-buffer (eglot--find-file-noselect " lib.py" ))
495+ (search-forward " geet" )
496+ (replace-match " greet" )
497+ (eglot--sniffing (:server-notifications s-notifs)
498+ (eglot--signal-textDocument/didChange)
499+ (set-buffer (eglot--find-file-noselect " main.py" ))
500+ (flymake-start)
501+ (cl-loop repeat 2
502+ do (eglot--wait-for (s-notifs 5 )
503+ (&key method &allow-other-keys)
504+ (string= method " $/streamDiagnostics" )))
505+ (goto-char (point-min ))
506+ (should-error (flymake-goto-next-error 1 '() t )))))
507+
441508(ert-deftest eglot-test-basic-symlink ()
442509 " Test basic symlink support."
443510 (skip-unless (executable-find " clangd" ))
@@ -708,6 +775,7 @@ directory hierarchy."
708775 ; ; This originally appeared in github#1339
709776 (skip-unless (executable-find " rust-analyzer" ))
710777 (skip-unless (executable-find " cargo" ))
778+ (skip-when (getenv " EMACS_EMBA_CI" ))
711779 (eglot--with-fixture
712780 '((" cmpl-project" .
713781 ((" main.rs" .
@@ -1002,26 +1070,31 @@ int main() {
10021070 (eglot--with-fixture
10031071 '((" project" .
10041072 ((" foo.c" . " const char write_data[] = u8\" 🚂🚃🚄🚅🚆🚈🚇🚈🚉🚊🚋🚌🚎🚝🚞🚟🚠🚡🛤🛲\" ;" ))))
1005- (let ((eglot-server-programs
1073+ (let (expected-column
1074+ (eglot-server-programs
10061075 '((c-mode . (" clangd" )))))
10071076 (with-current-buffer
10081077 (eglot--find-file-noselect " project/foo.c" )
1009- (setq-local eglot-move-to-linepos-function #'eglot-move-to-utf-16-linepos )
1010- (setq-local eglot-current-linepos-function #'eglot-utf-16-linepos )
10111078 (eglot--sniffing (:client-notifications c-notifs)
10121079 (eglot--tests-connect)
10131080 (end-of-line )
1081+
1082+ ; ; will be 71 if utf-16 was negotiated, 51 if utf-32,
1083+ ; ; something else if utf-8
1084+ (setq expected-column (funcall eglot-current-linepos-function))
1085+ (eglot--test-message
1086+ " Looks like we negotiated %S as the offset encoding"
1087+ (list eglot-move-to-linepos-function eglot-current-linepos-function))
10141088 (insert " p " )
10151089 (eglot--signal-textDocument/didChange)
10161090 (eglot--wait-for (c-notifs 2 ) (&key params &allow-other-keys)
1017- (message " PARAMS= %S " params)
1018- ( should ( equal 71 (eglot-tests--get
1091+ (should ( equal expected-column
1092+ (eglot-tests--get
10191093 params
10201094 '(:contentChanges 0
10211095 :range :start :character )))))
10221096 (beginning-of-line )
1023- (should (eq eglot-move-to-linepos-function #'eglot-move-to-utf-16-linepos ))
1024- (funcall eglot-move-to-linepos-function 71 )
1097+ (funcall eglot-move-to-linepos-function expected-column)
10251098 (should (looking-at " p" )))))))
10261099
10271100(ert-deftest eglot-test-lsp-abiding-column ()
@@ -1061,7 +1134,7 @@ int main() {
10611134 (let ((eglot-sync-connect t )
10621135 (eglot-server-programs
10631136 `((c-mode . (" sh" " -c" " sleep 1 && clangd" )))))
1064- (should (eglot--tests-connect 3 ))))))
1137+ (should (eglot--tests-connect :timeout 3 ))))))
10651138
10661139(ert-deftest eglot-test-slow-sync-connection-intime ()
10671140 " Connect synchronously with `eglot-sync-connect' set to 2."
@@ -1073,7 +1146,7 @@ int main() {
10731146 (let ((eglot-sync-connect 2 )
10741147 (eglot-server-programs
10751148 `((c-mode . (" sh" " -c" " sleep 1 && clangd" )))))
1076- (should (eglot--tests-connect 3 ))))))
1149+ (should (eglot--tests-connect :timeout 3 ))))))
10771150
10781151(ert-deftest eglot-test-slow-async-connection ()
10791152 " Connect asynchronously with `eglot-sync-connect' set to 2."
@@ -1466,6 +1539,10 @@ GUESSED-MAJOR-MODES-SYM are bound to the useful return values of
14661539 (should (string-suffix-p " c%3A/Users/Foo/bar.lisp"
14671540 (eglot-path-to-uri " c:/Users/Foo/bar.lisp" ))))
14681541
1542+ (ert-deftest eglot-test-path-to-uri-escape ()
1543+ (should (equal " file:///path/with%20%25%20funny%20%3F%20characters"
1544+ (eglot-path-to-uri " /path/with % funny ? characters" ))))
1545+
14691546(ert-deftest eglot-test-same-server-multi-mode ()
14701547 " Check single LSP instance manages multiple modes in same project."
14711548 (skip-unless (executable-find " clangd" ))
@@ -1491,6 +1568,58 @@ GUESSED-MAJOR-MODES-SYM are bound to the useful return values of
14911568 (eglot--find-file-noselect " project/foolib.c" )
14921569 (should (eq (eglot-current-server) server))))))
14931570
1571+ (defun eglot--semtok-faces () " Get semtok faces before point"
1572+ (get-text-property (1- (point )) 'eglot--semtok-faces ))
1573+
1574+ (defun eglot--semtok-wait (pos ) " Wait for semtok faces to appear after POS"
1575+ (eglot--with-timeout
1576+ '(3 " Timeout waiting for semantic tokens" )
1577+ (while (not (save-excursion
1578+ (goto-char pos)
1579+ (text-property-search-forward 'eglot--semtok-faces )))
1580+ (accept-process-output nil 0.1 )
1581+ (font-lock-ensure ))))
1582+
1583+ (ert-deftest eglot-test-semtok-basic ()
1584+ " Test basic semantic tokens fontification."
1585+ (skip-unless (executable-find " clangd" ))
1586+ (eglot--with-fixture
1587+ `((" project" . ((" main.c" . " int main() { int x = 42; return x; }" ))))
1588+ (with-current-buffer
1589+ (eglot--find-file-noselect " project/main.c" )
1590+ (eglot--tests-connect)
1591+ (should (eglot-server-capable :semanticTokensProvider ))
1592+ (should eglot-semantic-tokens-mode)
1593+ ; ; Trigger initial fontification, then wait for semantic tokens
1594+ (font-lock-ensure )
1595+ (eglot--semtok-wait (point-min ))
1596+ (goto-char (point-min ))
1597+ (search-forward " main" )
1598+ (should (memq 'eglot-semantic-function (eglot--semtok-faces)))
1599+ (search-forward " int x" )
1600+ (should (memq 'eglot-semantic-variable (eglot--semtok-faces))))))
1601+
1602+ (ert-deftest eglot-test-semtok-refontify ()
1603+ " Test semantic tokens refontification after edits."
1604+ (skip-unless (executable-find " clangd" ))
1605+ (eglot--with-fixture
1606+ `((" project" . ((" code.c" . " int foo() { return 0; }" ))))
1607+ (with-current-buffer
1608+ (eglot--find-file-noselect " project/code.c" )
1609+ (eglot--tests-connect)
1610+ (should eglot-semantic-tokens-mode)
1611+ (font-lock-ensure )
1612+ (eglot--semtok-wait (point-min ))
1613+ (goto-char (point-max ))
1614+ (save-excursion (insert " \n int bar() { int y = 10; return y; }" ))
1615+ (font-lock-ensure )
1616+ (eglot--signal-textDocument/didChange) ; a bit unrealistic
1617+ (eglot--semtok-wait (point ))
1618+ (search-forward " bar" )
1619+ (should (memq 'eglot-semantic-function (eglot--semtok-faces)))
1620+ (search-forward " int y" )
1621+ (should (memq 'eglot-semantic-variable (eglot--semtok-faces))))))
1622+
14941623(provide 'eglot-tests )
14951624
14961625; ; Local Variables:
0 commit comments