From 8b5532dd32b25276c1857508030b207f765ef9b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20T=C3=A1vora?= Date: Sat, 29 Apr 2023 20:33:24 +0100 Subject: [PATCH] Sync eglot.el and eglot-tests.el from upstream --- eglot-tests.el | 348 +++++++++---------- eglot.el | 881 ++++++++++++++++++++++++++++--------------------- 2 files changed, 660 insertions(+), 569 deletions(-) diff --git a/eglot-tests.el b/eglot-tests.el index 7ac26732..518f8810 100644 --- a/eglot-tests.el +++ b/eglot-tests.el @@ -37,8 +37,8 @@ ;; value (FIXME: like what?) in order to overwrite the default value. ;; ;; IMPORTANT: Since Eglot is a :core ELPA package, these tests are - ;;supposed to run on Emacsen down to 26.3. Do not use bleeding-edge - ;;functionality not compatible with that Emacs version. +;; supposed to run on Emacsen down to 26.3. Do not use bleeding-edge +;; functionality not compatible with that Emacs version. ;;; Code: (require 'eglot) @@ -47,7 +47,6 @@ (require 'tramp) (require 'ert-x) ; ert-simulate-command (require 'edebug) -(require 'python) ; some tests use pylsp (require 'cc-mode) ; c-mode-hook (require 'company nil t) (require 'yasnippet nil t) @@ -62,71 +61,56 @@ (apply #'format format args))) (defmacro eglot--with-fixture (fixture &rest body) - "Setup FIXTURE, call BODY, teardown FIXTURE. + "Set up FIXTURE, call BODY, tear down FIXTURE. FIXTURE is a list. Its elements are of the form (FILE . CONTENT) to create a readable FILE with CONTENT. FILE may be a directory name and CONTENT another (FILE . CONTENT) list to specify a -directory hierarchy. FIXTURE's elements can also be (SYMBOL -VALUE) meaning SYMBOL should be bound to VALUE during BODY and -then restored." +directory hierarchy." (declare (indent 1) (debug t)) - `(eglot--call-with-fixture - ,fixture #'(lambda () ,@body))) + `(eglot--call-with-fixture ,fixture (lambda () ,@body))) (defun eglot--make-file-or-dir (ass) - (let ((file-or-dir-name (car ass)) + (let ((file-or-dir-name (expand-file-name (car ass))) (content (cdr ass))) (cond ((listp content) (make-directory file-or-dir-name 'parents) - (let ((default-directory (concat default-directory "/" file-or-dir-name))) + (let ((default-directory (file-name-as-directory file-or-dir-name))) (mapcan #'eglot--make-file-or-dir content))) ((stringp content) - (with-temp-buffer - (insert content) - (write-region nil nil file-or-dir-name nil 'nomessage)) - (list (expand-file-name file-or-dir-name))) + (with-temp-file file-or-dir-name + (insert content)) + (list file-or-dir-name)) (t (eglot--error "Expected a string or a directory spec"))))) (defun eglot--call-with-fixture (fixture fn) "Helper for `eglot--with-fixture'. Run FN under FIXTURE." - (let* ((fixture-directory (make-nearby-temp-file "eglot--fixture" t)) - (default-directory fixture-directory) - file-specs created-files - syms-to-restore + (let* ((fixture-directory (make-nearby-temp-file "eglot--fixture-" t)) + (default-directory (file-name-as-directory fixture-directory)) + created-files new-servers test-body-successful-p) - (dolist (spec fixture) - (cond ((symbolp spec) - (push (cons spec (symbol-value spec)) syms-to-restore) - (set spec nil)) - ((symbolp (car spec)) - (push (cons (car spec) (symbol-value (car spec))) syms-to-restore) - (set (car spec) (cadr spec))) - ((stringp (car spec)) (push spec file-specs)))) (eglot--test-message "[%s]: test start" (ert-test-name (ert-running-test))) (unwind-protect - (let* ((process-environment - (append - `(;; Set XDF_CONFIG_HOME to /dev/null to prevent - ;; user-configuration to have an influence on - ;; language servers. (See github#441) - "XDG_CONFIG_HOME=/dev/null" - ;; ... on the flip-side, a similar technique by - ;; Emacs's test makefiles means that HOME is - ;; spoofed to /nonexistent, or sometimes /tmp. - ;; This breaks some common installations for LSP - ;; servers like pylsp, rust-analyzer making these - ;; tests mostly useless, so we hack around it here - ;; with a great big hack. - ,(format "HOME=%s" - (expand-file-name (format "~%s" (user-login-name))))) - process-environment)) - ;; Prevent "Can't guess python-indent-offset ..." messages. - (python-indent-guess-indent-offset-verbose . nil) - (eglot-server-initialized-hook - (lambda (server) (push server new-servers)))) - (setq created-files (mapcan #'eglot--make-file-or-dir file-specs)) + (let ((process-environment + `(;; Set XDG_CONFIG_HOME to /dev/null to prevent + ;; user-configuration influencing language servers + ;; (see github#441). + ,(format "XDG_CONFIG_HOME=%s" null-device) + ;; ... on the flip-side, a similar technique in + ;; Emacs's `test/Makefile' spoofs HOME as + ;; /nonexistent (and as `temporary-file-directory' in + ;; `ert-remote-temporary-file-directory'). + ;; This breaks some common installations for LSP + ;; servers like rust-analyzer, making these tests + ;; mostly useless, so we hack around it here with a + ;; great big hack. + ,(format "HOME=%s" + (expand-file-name (format "~%s" (user-login-name)))) + ,@process-environment)) + (eglot-server-initialized-hook + (lambda (server) (push server new-servers)))) + (setq created-files (mapcan #'eglot--make-file-or-dir fixture)) (prog1 (funcall fn) (setq test-body-successful-p t))) (eglot--test-message "[%s]: %s" (ert-test-name (ert-running-test)) @@ -158,18 +142,15 @@ then restored." (t (eglot--test-message "Preserved for inspection: %s" (mapconcat #'buffer-name buffers ", ")))))))) - (eglot--cleanup-after-test fixture-directory created-files syms-to-restore))))) + (eglot--cleanup-after-test fixture-directory created-files))))) -(defun eglot--cleanup-after-test (fixture-directory created-files syms-to-restore) +(defun eglot--cleanup-after-test (fixture-directory created-files) (let ((buffers-to-delete - (delete nil (mapcar #'find-buffer-visiting created-files)))) - (eglot--test-message "Killing %s, wiping %s, restoring %s" + (delq nil (mapcar #'find-buffer-visiting created-files)))) + (eglot--test-message "Killing %s, wiping %s" buffers-to-delete - fixture-directory - (mapcar #'car syms-to-restore)) - (cl-loop for (sym . val) in syms-to-restore - do (set sym val)) - (dolist (buf buffers-to-delete) ;; have to save otherwise will get prompted + fixture-directory) + (dolist (buf buffers-to-delete) ;; Have to save otherwise will get prompted. (with-current-buffer buf (save-buffer) (kill-buffer))) (delete-directory fixture-directory 'recursive) ;; Delete Tramp buffers if needed. @@ -314,6 +295,13 @@ then restored." (setq last-command-event char) (call-interactively (key-binding (vector char)))) +(defun eglot--clangd-version () + "Report on the clangd version used in various tests." + (let ((version (shell-command-to-string "clangd --version"))) + (when (string-match "version[[:space:]]+\\([0-9.]*\\)" + version) + (match-string 1 version)))) + ;;; Unit tests @@ -321,8 +309,7 @@ then restored." "Connect to eclipse.jdt.ls server." (skip-unless (executable-find "jdtls")) (eglot--with-fixture - '(("project/src/main/java/foo" . (("Main.java" . ""))) - ("project/.git/" . nil)) + '(("project/src/main/java/foo" . (("Main.java" . "")))) (with-current-buffer (eglot--find-file-noselect "project/src/main/java/foo/Main.java") (eglot--sniffing (:server-notifications s-notifs) @@ -452,6 +439,7 @@ then restored." (ert-deftest eglot-test-diagnostic-tags-unnecessary-code () "Test rendering of diagnostics tagged \"unnecessary\"." (skip-unless (executable-find "clangd")) + (skip-unless (version<= "14" (eglot--clangd-version))) (eglot--with-fixture `(("diag-project" . (("main.cpp" . "int main(){float a = 42.2; return 0;}")))) @@ -475,11 +463,11 @@ then restored." (should (eq 'eglot-diagnostic-tag-unnecessary-face (face-at-point)))))))) (defun eglot--eldoc-on-demand () - ;; Trick Eldoc 1.1.0 into accepting on-demand calls. + ;; Trick ElDoc 1.1.0 into accepting on-demand calls. (eldoc t)) (defun eglot--tests-force-full-eldoc () - ;; FIXME: This uses some Eldoc implementation defatils. + ;; FIXME: This uses some ElDoc implementation details. (when (buffer-live-p eldoc--doc-buffer) (with-current-buffer eldoc--doc-buffer (let ((inhibit-read-only t)) @@ -543,90 +531,101 @@ then restored." (should (equal (buffer-string) "int bar() {return 42;} int main() {return bar();}"))))) +(defun eglot--wait-for-clangd () + (eglot--sniffing (:server-notifications s-notifs) + (should (eglot--tests-connect)) + (eglot--wait-for (s-notifs 20) (&key method &allow-other-keys) + (string= method "textDocument/publishDiagnostics")))) + (ert-deftest eglot-test-basic-completions () - "Test basic autocompletion in a python LSP." - (skip-unless (executable-find "pylsp")) + "Test basic autocompletion in a clangd LSP." + (skip-unless (executable-find "clangd")) (eglot--with-fixture - `(("project" . (("something.py" . "import sys\nsys.exi")))) + `(("project" . (("coiso.c" . "#include \nint main () {fprin")))) (with-current-buffer - (eglot--find-file-noselect "project/something.py") - (should (eglot--tests-connect)) + (eglot--find-file-noselect "project/coiso.c") + (eglot--sniffing (:server-notifications s-notifs) + (eglot--wait-for-clangd) + (eglot--wait-for (s-notifs 20) (&key method &allow-other-keys) + (string= method "textDocument/publishDiagnostics"))) (goto-char (point-max)) (completion-at-point) - (should (looking-back "sys.exit"))))) + (message (buffer-string)) + (should (looking-back "fprintf.?"))))) (ert-deftest eglot-test-non-unique-completions () "Test completion resulting in 'Complete, but not unique'." - (skip-unless (executable-find "pylsp")) + (skip-unless (executable-find "clangd")) (eglot--with-fixture - '(("project" . (("something.py" . "foo=1\nfoobar=2\nfoo")))) + `(("project" . (("coiso.c" . + ,(concat "int foo; int fooey;" + "int main() {foo"))))) (with-current-buffer - (eglot--find-file-noselect "project/something.py") - (should (eglot--tests-connect)) + (eglot--find-file-noselect "project/coiso.c") + (eglot--wait-for-clangd) (goto-char (point-max)) - (completion-at-point)) - ;; FIXME: `current-message' doesn't work here :-( + (completion-at-point) + ;; FIXME: `current-message' doesn't work here :-( (with-current-buffer (messages-buffer) (save-excursion (goto-char (point-max)) (forward-line -1) - (should (looking-at "Complete, but not unique")))))) + (should (looking-at "Complete, but not unique"))))))) (ert-deftest eglot-test-basic-xref () - "Test basic xref functionality in a python LSP." - (skip-unless (executable-find "pylsp")) + "Test basic xref functionality in a clangd LSP." + (skip-unless (executable-find "clangd")) (eglot--with-fixture - `(("project" . (("something.py" . "def foo(): pass\ndef bar(): foo()")))) + `(("project" . (("coiso.c" . + ,(concat "int foo=42; int fooey;" + "int main() {foo=82;}"))))) (with-current-buffer - (eglot--find-file-noselect "project/something.py") + (eglot--find-file-noselect "project/coiso.c") (should (eglot--tests-connect)) - (search-forward "bar(): f") + (search-forward "{foo") (call-interactively 'xref-find-definitions) - (should (looking-at "foo(): pass"))))) + (should (looking-at "foo=42"))))) -(defvar eglot--test-python-buffer +(defvar eglot--test-c-buffer "\ -def foobarquux(a, b, c=True): pass -def foobazquuz(d, e, f): pass +void foobarquux(int a, int b, int c){}; +void foobazquuz(int a, int b, int f){}; +int main() { ") (declare-function yas-minor-mode nil) (ert-deftest eglot-test-snippet-completions () - "Test simple snippet completion in a python LSP." - (skip-unless (and (executable-find "pylsp") + "Test simple snippet completion in a clangd LSP." + (skip-unless (and (executable-find "clangd") (functionp 'yas-minor-mode))) (eglot--with-fixture - `(("project" . (("something.py" . ,eglot--test-python-buffer)))) + `(("project" . (("coiso.c" . ,eglot--test-c-buffer)))) (with-current-buffer - (eglot--find-file-noselect "project/something.py") + (eglot--find-file-noselect "project/coiso.c") (yas-minor-mode 1) - (let ((eglot-workspace-configuration - `((:pylsp . (:plugins (:jedi_completion (:include_params t))))))) - (should (eglot--tests-connect))) + (eglot--wait-for-clangd) (goto-char (point-max)) (insert "foobar") (completion-at-point) (should (looking-back "foobarquux(")) - (should (looking-at "a, b)"))))) + (should (looking-at "int a, int b, int c)"))))) (defvar company-candidates) (declare-function company-mode nil) (declare-function company-complete nil) (ert-deftest eglot-test-snippet-completions-with-company () - "Test simple snippet completion in a python LSP." - (skip-unless (and (executable-find "pylsp") + "Test simple snippet completion in a clangd LSP." + (skip-unless (and (executable-find "clangd") (functionp 'yas-minor-mode) (functionp 'company-complete))) (eglot--with-fixture - `(("project" . (("something.py" . ,eglot--test-python-buffer)))) + `(("project" . (("coiso.c" . ,eglot--test-c-buffer)))) (with-current-buffer - (eglot--find-file-noselect "project/something.py") + (eglot--find-file-noselect "project/coiso.c") (yas-minor-mode 1) - (let ((eglot-workspace-configuration - `((:pylsp . (:plugins (:jedi_completion (:include_params t))))))) - (should (eglot--tests-connect))) + (eglot--wait-for-clangd) (goto-char (point-max)) (insert "foo") (company-mode) @@ -634,98 +633,63 @@ def foobazquuz(d, e, f): pass (should (looking-back "fooba")) (should (= 2 (length company-candidates))) ;; this last one is brittle, since there it is possible that - ;; pylsp will change the representation of this candidate - (should (member "foobazquuz(d, e, f)" company-candidates))))) + ;; clangd will change the representation of this candidate + (should (member "foobazquuz(int a, int b, int f)" company-candidates))))) (ert-deftest eglot-test-eldoc-after-completions () - "Test documentation echo in a python LSP." - (skip-unless (executable-find "pylsp")) + "Test documentation echo in a clangd LSP." + (skip-unless (executable-find "clangd")) (eglot--with-fixture - `(("project" . (("something.py" . "import sys\nsys.exi")))) + `(("project" . (("coiso.c" . "#include \nint main () {fprin")))) (with-current-buffer - (eglot--find-file-noselect "project/something.py") - (should (eglot--tests-connect)) + (eglot--find-file-noselect "project/coiso.c") + (eglot--wait-for-clangd) (goto-char (point-max)) (completion-at-point) - (should (looking-back "sys.exit")) - (should (string-match "^exit" (eglot--tests-force-full-eldoc)))))) + (message (buffer-string)) + (should (looking-back "fprintf(?")) + (unless (= (char-before) ?\() (insert "()") (backward-char)) + (eglot--signal-textDocument/didChange) + (should (string-match "^fprintf" (eglot--tests-force-full-eldoc)))))) (ert-deftest eglot-test-multiline-eldoc () - "Test if suitable amount of lines of hover info are shown." - (skip-unless (executable-find "pylsp")) + "Test ElDoc documentation from multiple osurces." + (skip-unless (executable-find "clangd")) (eglot--with-fixture - `(("project" . (("hover-first.py" . "from datetime import datetime")))) + `(("project" . (("coiso.c" . + "#include \nint main () {fprintf(blergh);}")))) (with-current-buffer - (eglot--find-file-noselect "project/hover-first.py") - (should (eglot--tests-connect)) - (goto-char (point-max)) - ;; one-line - (let* ((eldoc-echo-area-use-multiline-p t) - (captured-message (eglot--tests-force-full-eldoc))) - (should (string-match "datetim" captured-message)) + (eglot--find-file-noselect "project/coiso.c") + (search-forward "fprintf(ble") + (eglot--wait-for-clangd) + (flymake-start nil t) ;; thing brings in the "unknown identifier blergh" + (let* ((captured-message (eglot--tests-force-full-eldoc))) + ;; check for signature and error message in the result + (should (string-match "fprintf" captured-message)) + (should (string-match "blergh" captured-message)) (should (cl-find ?\n captured-message)))))) -(ert-deftest eglot-test-single-line-eldoc () - "Test if suitable amount of lines of hover info are shown." - (skip-unless (executable-find "pylsp")) - (eglot--with-fixture - `(("project" . (("hover-first.py" . "from datetime import datetime")))) - (with-current-buffer - (eglot--find-file-noselect "project/hover-first.py") - (should (eglot--tests-connect)) - (goto-char (point-max)) - ;; one-line - (let* ((eldoc-echo-area-use-multiline-p nil) - (captured-message (eglot--tests-force-full-eldoc))) - (should (string-match "datetim" captured-message)) - (should (not (cl-find ?\n eldoc-last-message))))))) - -(ert-deftest eglot-test-python-autopep-formatting () - "Test formatting in the pylsp python LSP. -pylsp prefers autopep over yafp, despite its README stating the contrary." +(ert-deftest eglot-test-formatting () + "Test formatting in the clangd server." ;; Beware, default autopep rules can change over time, which may ;; affect this test. - (skip-unless (and (executable-find "pylsp") - (executable-find "autopep8"))) - (eglot--with-fixture - `(("project" . (("something.py" . "def a():pass\n\ndef b():pass")))) - (with-current-buffer - (eglot--find-file-noselect "project/something.py") - (should (eglot--tests-connect)) - ;; Try to format just the second line - (search-forward "b():pa") - (eglot-format (line-beginning-position) (line-end-position)) - (should (looking-at "ss")) - (should - (or (string= (buffer-string) "def a():pass\n\n\ndef b(): pass\n") - ;; autopep8 2.0.0 (pycodestyle: 2.9.1) - (string= (buffer-string) "def a():pass\n\ndef b(): pass"))) - ;; now format the whole buffer - (eglot-format-buffer) - (should - (string= (buffer-string) "def a(): pass\n\n\ndef b(): pass\n"))))) - -(ert-deftest eglot-test-python-yapf-formatting () - "Test formatting in the pylsp python LSP." - (skip-unless (and (executable-find "pylsp") - (not (executable-find "autopep8")) - (or (executable-find "yapf") - (executable-find "yapf3")))) + (skip-unless (executable-find "clangd")) (eglot--with-fixture - `(("project" . (("something.py" . "def a():pass\ndef b():pass")))) + `(("project" . (("coiso.c" . ,(concat "#include \n" + "int main(){fprintf(blergh);}" + "int ble{\n\nreturn 0;}"))))) (with-current-buffer - (eglot--find-file-noselect "project/something.py") - (should (eglot--tests-connect)) + (eglot--find-file-noselect "project/coiso.c") + (eglot--wait-for-clangd) + (forward-line) ;; Try to format just the second line - (search-forward "b():pa") (eglot-format (line-beginning-position) (line-end-position)) - (should (looking-at "ss")) - (should - (string= (buffer-string) "def a():pass\n\n\ndef b():\n pass\n")) - ;; now format the whole buffer + (should (looking-at "int main() { fprintf(blergh); }")) + ;; ;; now format the whole buffer (eglot-format-buffer) (should - (string= (buffer-string) "def a():\n pass\n\n\ndef b():\n pass\n"))))) + (string= (buffer-string) + "#include \nint main() { fprintf(blergh); }\nint ble { return 0; }"))))) (ert-deftest eglot-test-rust-on-type-formatting () "Test textDocument/onTypeFormatting against rust-analyzer." @@ -742,7 +706,7 @@ pylsp prefers autopep over yafp, despite its README stating the contrary." (eglot--sniffing (:server-notifications s-notifs) (should (eglot--tests-connect)) (eglot--wait-for (s-notifs 20) (&key method &allow-other-keys) - (string= method "textDocument/publishDiagnostics"))) + (string= method "textDocument/publishDiagnostics"))) (goto-char (point-max)) (eglot--simulate-key-event ?.) (should (looking-back "^ \\.")))))) @@ -840,6 +804,7 @@ pylsp prefers autopep over yafp, despite its README stating the contrary." (ert-deftest eglot-test-json-basic () "Test basic autocompletion in vscode-json-languageserver." (skip-unless (executable-find "vscode-json-languageserver")) + (skip-unless (fboundp 'yas-minor-mode)) (eglot--with-fixture '(("project" . (("p.json" . "{\"foo.b") @@ -891,9 +856,9 @@ pylsp prefers autopep over yafp, despite its README stating the contrary." (skip-unless (executable-find "clangd")) (eglot--with-fixture `(("project" . (("foo.c" . "int foo() {return 42;}") - ("bar.c" . "int bar() {return 42;}"))) - (c-mode-hook (eglot-ensure))) - (let (server) + ("bar.c" . "int bar() {return 42;}")))) + (let ((c-mode-hook '(eglot-ensure)) + server) ;; need `ert-simulate-command' because `eglot-ensure' ;; relies on `post-command-hook'. (with-current-buffer @@ -1077,7 +1042,8 @@ pylsp prefers autopep over yafp, despite its README stating the contrary." (cl-defmacro eglot--guessing-contact ((interactive-sym prompt-args-sym guessed-class-sym guessed-contact-sym - &optional guessed-lang-id-sym) + &optional guessed-major-modes-sym + guessed-lang-ids-sym) &body body) "Guess LSP contact with `eglot--guessing-contact', evaluate BODY. @@ -1087,10 +1053,10 @@ BODY is evaluated twice, with INTERACTIVE bound to the boolean passed to If the user would have been prompted, PROMPT-ARGS-SYM is bound to the list of arguments that would have been passed to `read-shell-command', else nil. GUESSED-CLASS-SYM, -GUESSED-CONTACT-SYM and GUESSED-LANG-ID-SYM are bound to the -useful return values of `eglot--guess-contact'. Unless the -server program evaluates to \"a-missing-executable.exe\", this -macro will assume it exists." +GUESSED-CONTACT-SYM, GUESSED-LANG-IDS-SYM and +GUESSED-MAJOR-MODES-SYM are bound to the useful return values of +`eglot--guess-contact'. Unless the server program evaluates to +\"a-missing-executable.exe\", this macro will assume it exists." (declare (indent 1) (debug t)) (let ((i-sym (cl-gensym))) `(dolist (,i-sym '(nil t)) @@ -1106,8 +1072,9 @@ macro will assume it exists." `(lambda (&rest args) (setq ,prompt-args-sym args) "") `(lambda (&rest _dummy) "")))) (cl-destructuring-bind - (_ _ ,guessed-class-sym ,guessed-contact-sym - ,(or guessed-lang-id-sym '_)) + (,(or guessed-major-modes-sym '_) + _ ,guessed-class-sym ,guessed-contact-sym + ,(or guessed-lang-ids-sym '_)) (eglot--guess-contact ,i-sym) ,@body)))))) @@ -1202,16 +1169,17 @@ macro will assume it exists." (ert-deftest eglot-test-server-programs-guess-lang () (let ((major-mode 'foo-mode)) (let ((eglot-server-programs '((foo-mode . ("prog-executable"))))) - (eglot--guessing-contact (_ nil _ _ guessed-lang) - (should (equal guessed-lang "foo")))) + (eglot--guessing-contact (_ nil _ _ _ guessed-langs) + (should (equal guessed-langs '("foo"))))) (let ((eglot-server-programs '(((foo-mode :language-id "bar") . ("prog-executable"))))) - (eglot--guessing-contact (_ nil _ _ guessed-lang) - (should (equal guessed-lang "bar")))) + (eglot--guessing-contact (_ nil _ _ _ guessed-langs) + (should (equal guessed-langs '("bar"))))) (let ((eglot-server-programs '(((baz-mode (foo-mode :language-id "bar")) . ("prog-executable"))))) - (eglot--guessing-contact (_ nil _ _ guessed-lang) - (should (equal guessed-lang "bar")))))) + (eglot--guessing-contact (_ nil _ _ modes guessed-langs) + (should (equal guessed-langs '("bar" "baz"))) + (should (equal modes '(foo-mode baz-mode))))))) (defun eglot--glob-match (glob str) (funcall (eglot--glob-compile glob t t) str)) @@ -1276,6 +1244,7 @@ macro will assume it exists." (let* ((tramp-remote-path (cons 'tramp-own-remote-path tramp-remote-path)) (tramp-histfile-override t) + (tramp-allow-unsafe-temporary-files t) (tramp-verbose 1) (temporary-file-directory (or (bound-and-true-p ert-remote-temporary-file-directory) @@ -1306,7 +1275,7 @@ macro will assume it exists." (ert-deftest eglot-test-path-to-uri-windows () (skip-unless (eq system-type 'windows-nt)) (should (string-prefix-p "file:///" - (eglot--path-to-uri "c:/Users/Foo/bar.lisp"))) + (eglot--path-to-uri "c:/Users/Foo/bar.lisp"))) (should (string-suffix-p "c%3A/Users/Foo/bar.lisp" (eglot--path-to-uri "c:/Users/Foo/bar.lisp")))) @@ -1336,8 +1305,9 @@ macro will assume it exists." (should (eq (eglot-current-server) server)))))) (provide 'eglot-tests) -;;; eglot-tests.el ends here ;; Local Variables: ;; checkdoc-force-docstrings-flag: nil ;; End: + +;;; eglot-tests.el ends here diff --git a/eglot.el b/eglot.el index 6c1b9eaf..1e0bcd30 100644 --- a/eglot.el +++ b/eglot.el @@ -2,12 +2,12 @@ ;; Copyright (C) 2018-2023 Free Software Foundation, Inc. -;; Version: 1.13 +;; Version: 1.15 ;; Author: João Távora ;; Maintainer: João Távora ;; URL: https://github.com/joaotavora/eglot ;; Keywords: convenience, languages -;; Package-Requires: ((emacs "26.3") (jsonrpc "1.0.16") (flymake "1.2.1") (project "0.9.8") (xref "1.6.2") (eldoc "1.11.0") (seq "2.23") (external-completion "0.1")) +;; Package-Requires: ((emacs "26.3") (jsonrpc "1.0.16") (flymake "1.2.1") (project "0.9.8") (xref "1.6.2") (eldoc "1.14.0") (seq "2.23") (external-completion "0.1")) ;; This is a GNU ELPA :core package. Avoid adding functionality ;; that is not available in the version of Emacs recorded above or any @@ -97,34 +97,30 @@ (require 'imenu) (require 'cl-lib) -(require 'project) + (require 'url-parse) (require 'url-util) (require 'pcase) (require 'compile) ; for some faces (require 'warnings) -(require 'flymake) -(require 'xref) (eval-when-compile (require 'subr-x)) -(require 'jsonrpc) (require 'filenotify) (require 'ert) -(require 'array) -(require 'external-completion) - -;; ElDoc is preloaded in Emacs, so `require'-ing won't guarantee we are -;; using the latest version from GNU Elpa when we load eglot.el. Use an -;; heuristic to see if we need to `load' it in Emacs < 28. -(if (and (< emacs-major-version 28) - (not (boundp 'eldoc-documentation-strategy))) - (load "eldoc") - (require 'eldoc)) - -;; Similar issue as above for Emacs 26.3 and seq.el. -(if (< emacs-major-version 27) - (load "seq") - (require 'seq)) +(require 'text-property-search nil t) + +;; These dependencies are also GNU ELPA core packages. Because of +;; bug#62576, since there is a risk that M-x package-install, despite +;; having installed them, didn't correctly re-load them over the +;; built-in versions. +(eval-and-compile + (load "project") + (load "eldoc") + (load "seq") + (load "flymake") + (load "xref") + (load "jsonrpc") + (load "external-completion")) ;; forward-declare, but don't require (Emacs 28 doesn't seem to care) (defvar markdown-fontify-code-blocks-natively) @@ -219,9 +215,13 @@ chosen (interactively or automatically)." ((R-mode ess-r-mode) . ("R" "--slave" "-e" "languageserver::run()")) ((java-mode java-ts-mode) . ("jdtls")) - (dart-mode . ("dart" "language-server" - "--client-id" "emacs.eglot-dart")) - ((elixir-ts-mode elixir-mode) . ("language_server.sh")) + ((dart-mode dart-ts-mode) + . ("dart" "language-server" + "--client-id" "emacs.eglot-dart")) + ((elixir-mode elixir-ts-mode heex-ts-mode) + . ,(if (and (fboundp 'w32-shell-dos-semantics) (w32-shell-dos-semantics)) + '("language_server.bat") + '("language_server.sh"))) (ada-mode . ("ada_language_server")) (scala-mode . ,(eglot-alternatives '("metals" "metals-emacs"))) @@ -242,7 +242,7 @@ chosen (interactively or automatically)." ("css-languageserver" "--stdio")))) (html-mode . ,(eglot-alternatives '(("vscode-html-language-server" "--stdio") ("html-languageserver" "--stdio")))) ((dockerfile-mode dockerfile-ts-mode) . ("docker-langserver" "--stdio")) - ((clojure-mode clojurescript-mode clojurec-mode) + ((clojure-mode clojurescript-mode clojurec-mode clojure-ts-mode) . ("clojure-lsp")) ((csharp-mode csharp-ts-mode) . ,(eglot-alternatives @@ -250,7 +250,11 @@ chosen (interactively or automatically)." ("csharp-ls")))) (purescript-mode . ("purescript-language-server" "--stdio")) ((perl-mode cperl-mode) . ("perl" "-MPerl::LanguageServer" "-e" "Perl::LanguageServer::run")) - (markdown-mode . ("marksman" "server"))) + (markdown-mode + . ,(eglot-alternatives + '(("marksman" "server") + ("vscode-markdown-language-server" "--stdio")))) + (graphviz-dot-mode . ("dot-language-server" "--stdio"))) "How the command `eglot' guesses the server to start. An association list of (MAJOR-MODE . CONTACT) pairs. MAJOR-MODE identifies the buffers that are to be managed by a specific @@ -390,14 +394,20 @@ done by `eglot-reconnect'." "If non-nil, activate Eglot in cross-referenced non-project files." :type 'boolean) +(defcustom eglot-prefer-plaintext nil + "If non-nil, always request plaintext responses to hover requests." + :type 'boolean) + (defcustom eglot-menu-string "eglot" "String displayed in mode line when Eglot is active." :type 'string) (defcustom eglot-report-progress t - "If non-nil, show progress of long running LSP server work" + "If non-nil, show progress of long running LSP server work. +If set to `messages', use *Messages* buffer, else use Eglot's +mode line indicator." :type 'boolean - :version "29.1") + :version "1.10") (defvar eglot-withhold-process-id nil "If non-nil, Eglot will not send the Emacs process id to the language server. @@ -442,6 +452,10 @@ This can be useful when using docker to run a language server.") (if (>= emacs-major-version 27) (executable-find command remote) (executable-find command))) +(defun eglot--accepted-formats () + (if (and (not eglot-prefer-plaintext) (fboundp 'gfm-view-mode)) + ["markdown" "plaintext"] ["plaintext"])) + ;;; Message verification helpers ;;; @@ -477,9 +491,7 @@ This can be useful when using docker to run a language server.") (SymbolInformation (:name :kind :location) (:deprecated :containerName)) (DocumentSymbol (:name :range :selectionRange :kind) - ;; `:containerName' isn't really allowed , but - ;; it simplifies the impl of `eglot-imenu'. - (:detail :deprecated :children :containerName)) + (:detail :deprecated :children)) (TextDocumentEdit (:textDocument :edits) ()) (TextEdit (:range :newText)) (VersionedTextDocumentIdentifier (:uri :version) ()) @@ -759,7 +771,9 @@ treated as in `eglot--dbind'." :completion (list :dynamicRegistration :json-false :completionItem `(:snippetSupport - ,(if (eglot--snippet-expansion-fn) + ,(if (and + (not (eglot--stay-out-of-p 'yasnippet)) + (eglot--snippet-expansion-fn)) t :json-false) :deprecatedSupport t @@ -770,14 +784,12 @@ treated as in `eglot--dbind'." :tagSupport (:valueSet [1])) :contextSupport t) :hover (list :dynamicRegistration :json-false - :contentFormat - (if (fboundp 'gfm-view-mode) - ["markdown" "plaintext"] - ["plaintext"])) + :contentFormat (eglot--accepted-formats)) :signatureHelp (list :dynamicRegistration :json-false :signatureInformation `(:parameterInformation (:labelOffsetSupport t) + :documentationFormat ,(eglot--accepted-formats) :activeParameterSupport t)) :references `(:dynamicRegistration :json-false) :definition (list :dynamicRegistration :json-false @@ -818,6 +830,7 @@ treated as in `eglot--dbind'." `(:valueSet [,@(mapcar #'car eglot--tag-faces)]))) + :window `(:workDoneProgress t) :general (list :positionEncodings ["utf-32" "utf-8" "utf-16"]) :experimental eglot--{}))) @@ -835,12 +848,9 @@ treated as in `eglot--dbind'." :documentation "Short nickname for the associated project." :accessor eglot--project-nickname :reader eglot-project-nickname) - (major-modes - :documentation "Major modes server is responsible for in a given project." - :accessor eglot--major-modes) - (language-id - :documentation "Language ID string for the mode." - :accessor eglot--language-id) + (languages + :documentation "Alist ((MODE . LANGUAGE-ID-STRING)...) of managed languages." + :accessor eglot--languages) (capabilities :documentation "JSON object containing server capabilities." :accessor eglot--capabilities) @@ -875,6 +885,12 @@ treated as in `eglot--dbind'." :documentation "Represents a server. Wraps a process for LSP communication.") +(defun eglot--major-modes (s) "Major modes server S is responsible for." + (mapcar #'car (eglot--languages s))) + +(defun eglot--language-ids (s) "LSP Language ID strings for server S's modes." + (mapcar #'cdr (eglot--languages s))) + (cl-defmethod initialize-instance :before ((_server eglot-lsp-server) &optional args) (cl-remf args :initializationOptions)) @@ -902,7 +918,7 @@ SERVER." (unwind-protect (progn (setf (eglot--shutdown-requested server) t) - (jsonrpc-request server :shutdown nil :timeout (or timeout 1.5)) + (eglot--request server :shutdown nil :timeout (or timeout 1.5)) (jsonrpc-notify server :exit nil)) ;; Now ask jsonrpc.el to shut down the server. (jsonrpc-shutdown server (not preserve-buffers)) @@ -960,42 +976,44 @@ PRESERVE-BUFFERS as in `eglot-shutdown', which see." (defun eglot--lookup-mode (mode) "Lookup `eglot-server-programs' for MODE. -Return (MANAGED-MODES LANGUAGE-ID CONTACT-PROXY). +Return (LANGUAGES . CONTACT-PROXY). MANAGED-MODES is a list with MODE as its first element. Subsequent elements are other major modes also potentially managed by the server that is to manage MODE. -If not specified in `eglot-server-programs' (which see), -LANGUAGE-ID is determined from MODE's name. +LANGUAGE-IDS is a list of the same length as MANAGED-MODES. Each +elem is derived from the corresponding mode name, if not +specified in `eglot-server-programs' (which see). CONTACT-PROXY is the value of the corresponding `eglot-server-programs' entry." - (cl-loop - for (modes . contact) in eglot-server-programs - for mode-symbols = (cons mode - (delete mode - (mapcar #'car - (mapcar #'eglot--ensure-list - (eglot--ensure-list modes))))) - thereis (cl-some - (lambda (spec) - (cl-destructuring-bind (probe &key language-id &allow-other-keys) - (eglot--ensure-list spec) - (and (provided-mode-derived-p mode probe) - (list - mode-symbols - (or language-id - (or (get mode 'eglot-language-id) - (get spec 'eglot-language-id) - (string-remove-suffix "-mode" (symbol-name mode)))) - contact)))) - (if (or (symbolp modes) (keywordp (cadr modes))) - (list modes) modes)))) + (cl-flet ((languages (main-mode-sym specs) + (let* ((res + (mapcar (jsonrpc-lambda (sym &key language-id &allow-other-keys) + (cons sym + (or language-id + (or (get sym 'eglot-language-id) + (replace-regexp-in-string + "\\(?:-ts\\)?-mode$" "" + (symbol-name sym)))))) + specs)) + (head (cl-find main-mode-sym res :key #'car))) + (cons head (delq head res))))) + (cl-loop + for (modes . contact) in eglot-server-programs + for specs = (mapcar #'eglot--ensure-list + (if (or (symbolp modes) (keywordp (cadr modes))) + (list modes) modes)) + thereis (cl-some (lambda (spec) + (cl-destructuring-bind (sym &key &allow-other-keys) spec + (and (provided-mode-derived-p mode sym) + (cons (languages sym specs) contact)))) + specs)))) (defun eglot--guess-contact (&optional interactive) "Helper for `eglot'. -Return (MANAGED-MODE PROJECT CLASS CONTACT LANG-ID). If INTERACTIVE is +Return (MANAGED-MODES PROJECT CLASS CONTACT LANG-IDS). If INTERACTIVE is non-nil, maybe prompt user, else error as soon as something can't be guessed." (let* ((guessed-mode (if buffer-file-name major-mode)) @@ -1013,11 +1031,10 @@ be guessed." ((not guessed-mode) (eglot--error "Can't guess mode to manage for `%s'" (current-buffer))) (t guessed-mode))) - (triplet (eglot--lookup-mode main-mode)) - (managed-modes (car triplet)) - (language-id (or (cadr triplet) - (string-remove-suffix "-mode" (symbol-name guessed-mode)))) - (guess (caddr triplet)) + (languages-and-contact (eglot--lookup-mode main-mode)) + (managed-modes (mapcar #'car (car languages-and-contact))) + (language-ids (mapcar #'cdr (car languages-and-contact))) + (guess (cdr languages-and-contact)) (guess (if (functionp guess) (funcall guess interactive) guess)) @@ -1065,7 +1082,7 @@ be guessed." full-program-invocation 'eglot-command-history))) guess))) - (list managed-modes (eglot--current-project) class contact language-id))) + (list managed-modes (eglot--current-project) class contact language-ids))) (defvar eglot-lsp-context) (put 'eglot-lsp-context 'variable-documentation @@ -1083,24 +1100,25 @@ suitable root directory for a given LSP server's purposes." `(transient . ,(expand-file-name default-directory))))) ;;;###autoload -(defun eglot (managed-major-mode project class contact language-id +(defun eglot (managed-major-modes project class contact language-ids &optional _interactive) - "Start LSP server in support of PROJECT's buffers under MANAGED-MAJOR-MODE. + "Start LSP server for PROJECT's buffers under MANAGED-MAJOR-MODES. -This starts a Language Server Protocol (LSP) server suitable for the -buffers of PROJECT whose `major-mode' is MANAGED-MAJOR-MODE. -CLASS is the class of the LSP server to start and CONTACT specifies -how to connect to the server. +This starts a Language Server Protocol (LSP) server suitable for +the buffers of PROJECT whose `major-mode' is among +MANAGED-MAJOR-MODES. CLASS is the class of the LSP server to +start and CONTACT specifies how to connect to the server. -Interactively, the command attempts to guess MANAGED-MAJOR-MODE -from the current buffer's `major-mode', CLASS and CONTACT from -`eglot-server-programs' looked up by the major mode, and PROJECT from -`project-find-functions'. The search for active projects in this -context binds `eglot-lsp-context' (which see). +Interactively, the command attempts to guess MANAGED-MAJOR-MODES, +CLASS, CONTACT, and LANGUAGE-IDS from `eglot-server-programs', +according to the current buffer's `major-mode'. PROJECT is +guessed from `project-find-functions'. The search for active +projects in this context binds `eglot-lsp-context' (which see). -If it can't guess, it prompts the user for the mode and the server. -With a single \\[universal-argument] prefix arg, it always prompts for COMMAND. -With two \\[universal-argument], it also always prompts for MANAGED-MAJOR-MODE. +If it can't guess, it prompts the user for the mode and the +server. With a single \\[universal-argument] prefix arg, it +always prompts for COMMAND. With two \\[universal-argument], it +also always prompts for MANAGED-MAJOR-MODE. The LSP server of CLASS is started (or contacted) via CONTACT. If this operation is successful, current *and future* file @@ -1118,8 +1136,8 @@ CONTACT specifies how to contact the server. It is a keyword-value plist used to initialize CLASS or a plain list as described in `eglot-server-programs', which see. -LANGUAGE-ID is the language ID string to send to the server for -MANAGED-MAJOR-MODE, which matters to a minority of servers. +LANGUAGE-IDS is a list of language ID string to send to the +server for each element in MANAGED-MAJOR-MODES. INTERACTIVE is ignored and provided for backward compatibility." (interactive @@ -1130,8 +1148,9 @@ INTERACTIVE is ignored and provided for backward compatibility." (user-error "[eglot] Connection attempt aborted by user.")) (prog1 (append (eglot--guess-contact t) '(t)) (when current-server (ignore-errors (eglot-shutdown current-server)))))) - (eglot--connect (eglot--ensure-list managed-major-mode) - project class contact language-id)) + (eglot--connect (eglot--ensure-list managed-major-modes) + project class contact + (eglot--ensure-list language-ids))) (defun eglot-reconnect (server &optional interactive) "Reconnect to SERVER. @@ -1143,7 +1162,7 @@ INTERACTIVE is t if called interactively." (eglot--project server) (eieio-object-class-name server) (eglot--saved-initargs server) - (eglot--language-id server)) + (eglot--language-ids server)) (eglot--message "Reconnected!")) (defvar eglot--managed-mode) ; forward decl @@ -1216,8 +1235,8 @@ Each function is passed the server as an argument") (defvar-local eglot--cached-server nil "A cached reference to the current Eglot server.") -(defun eglot--connect (managed-modes project class contact language-id) - "Connect to MANAGED-MODES, LANGUAGE-ID, PROJECT, CLASS and CONTACT. +(defun eglot--connect (managed-modes project class contact language-ids) + "Connect to MANAGED-MODES, LANGUAGE-IDS, PROJECT, CLASS and CONTACT. This docstring appeases checkdoc, that's all." (let* ((default-directory (project-root project)) (nickname (project-name project)) @@ -1257,7 +1276,7 @@ This docstring appeases checkdoc, that's all." ;; very much data so we turn it off ;; unconditionally -- just for our ;; process. - (tramp-use-ssh-controlmaster-options t) + (tramp-use-ssh-controlmaster-options 'suppress) (tramp-ssh-controlmaster-options "-o ControlMaster=no -o ControlPath=none")) (make-process @@ -1290,8 +1309,9 @@ This docstring appeases checkdoc, that's all." (setf (eglot--saved-initargs server) initargs) (setf (eglot--project server) project) (setf (eglot--project-nickname server) nickname) - (setf (eglot--major-modes server) (eglot--ensure-list managed-modes)) - (setf (eglot--language-id server) language-id) + (setf (eglot--languages server) + (cl-loop for m in managed-modes for l in language-ids + collect (cons m l))) (setf (eglot--inferior-process server) autostart-inferior-process) (run-hook-with-args 'eglot-server-initialized-hook server) ;; Now start the handshake. To honor `eglot-sync-connect' @@ -1310,6 +1330,7 @@ This docstring appeases checkdoc, that's all." (eq (jsonrpc-process-type server) 'network)) (emacs-pid)) + :clientInfo '(:name "Eglot") ;; Maybe turn trampy `/ssh:foo@bar:/path/to/baz.py' ;; into `/path/to/baz.py', so LSP groks it. :rootPath (file-local-name @@ -1454,15 +1475,27 @@ CONNECT-ARGS are passed as additional arguments to (line-beginning-position n)))) "Return position of first character in current line.") +(cl-defun eglot--request (server method params &key + immediate + timeout cancel-on-input + cancel-on-input-retval) + "Like `jsonrpc-request', but for Eglot LSP requests. +Unless IMMEDIATE, send pending changes before making request." + (unless immediate (eglot--signal-textDocument/didChange)) + (jsonrpc-request server method params + :timeout timeout + :cancel-on-input cancel-on-input + :cancel-on-input-retval cancel-on-input-retval)) + ;;; Encoding fever ;;; (define-obsolete-function-alias - 'eglot-lsp-abiding-column 'eglot-utf-16-linepos "29.1") + 'eglot-lsp-abiding-column 'eglot-utf-16-linepos "1.12") (define-obsolete-function-alias - 'eglot-current-column 'eglot-utf-32-linepos "29.1") + 'eglot-current-column 'eglot-utf-32-linepos "1.12") (define-obsolete-variable-alias - 'eglot-current-column-function 'eglot-current-linepos-function "29.1") + 'eglot-current-column-function 'eglot-current-linepos-function "1.12") (defvar eglot-current-linepos-function #'eglot-utf-16-linepos "Function calculating position relative to line beginning. @@ -1503,11 +1536,11 @@ LBP defaults to `eglot--bol'." (funcall eglot-current-linepos-function))))) (define-obsolete-function-alias - 'eglot-move-to-current-column 'eglot-move-to-utf-32-linepos "29.1") + 'eglot-move-to-current-column 'eglot-move-to-utf-32-linepos "1.12") (define-obsolete-function-alias - 'eglot-move-to-lsp-abiding-column 'eglot-move-to-utf-16-linepos "29.1") + 'eglot-move-to-lsp-abiding-column 'eglot-move-to-utf-16-linepos "1.12") (define-obsolete-variable-alias -'eglot-move-to-column-function 'eglot-move-to-linepos-function "29.1") +'eglot-move-to-column-function 'eglot-move-to-linepos-function "1.12") (defvar eglot-move-to-linepos-function #'eglot-move-to-utf-16-linepos "Function to move to a position within a line reported by the LSP server. @@ -1603,6 +1636,7 @@ If optional MARKER, return a marker instead" (directory-file-name (file-local-name truepath)) eglot--uri-path-allowed-chars))))) +(declare-function w32-long-file-name "w32proc.c" (fn)) (defun eglot--uri-to-path (uri) "Convert URI to file path, helped by `eglot--current-server'." (when (keywordp uri) (setq uri (substring (symbol-name uri) 1))) @@ -1618,18 +1652,19 @@ If optional MARKER, return a marker instead" (normalized (if (and (not remote-prefix) (eq system-type 'windows-nt) (cl-plusp (length retval))) - (substring retval 1) + (w32-long-file-name (substring retval 1)) retval))) (concat remote-prefix normalized)) - uri))) (defun eglot--snippet-expansion-fn () "Compute a function to expand snippets. Doubles as an indicator of snippet support." - (and (boundp 'yas-minor-mode) - (symbol-value 'yas-minor-mode) - 'yas-expand-snippet)) + (and (fboundp 'yas-minor-mode) + (lambda (&rest args) + (with-no-warnings + (unless (bound-and-true-p yas-minor-mode) (yas-minor-mode 1)) + (apply #'yas-expand-snippet args))))) (defun eglot--format-markup (markup) "Format MARKUP according to LSP's spec." @@ -1644,10 +1679,17 @@ Doubles as an indicator of snippet support." (setq-local markdown-fontify-code-blocks-natively t) (insert string) (let ((inhibit-message t) - (message-log-max nil)) - (ignore-errors (delay-mode-hooks (funcall mode)))) - (font-lock-ensure) - (string-trim (buffer-string))))) + (message-log-max nil) + match) + (ignore-errors (delay-mode-hooks (funcall mode))) + (font-lock-ensure) + (goto-char (point-min)) + (let ((inhibit-read-only t)) + (when (fboundp 'text-property-search-forward) ;; FIXME: use compat + (while (setq match (text-property-search-forward 'invisible)) + (delete-region (prop-match-beginning match) + (prop-match-end match))))) + (string-trim (buffer-string)))))) (define-obsolete-variable-alias 'eglot-ignored-server-capabilites 'eglot-ignored-server-capabilities "1.8") @@ -1665,7 +1707,7 @@ under cursor." (const :tag "Go to definition" :definitionProvider) (const :tag "Go to type definition" :typeDefinitionProvider) (const :tag "Go to implementation" :implementationProvider) - (const :tag "Go to declaration" :implementationProvider) + (const :tag "Go to declaration" :declarationProvider) (const :tag "Find references" :referencesProvider) (const :tag "Highlight symbols automatically" :documentHighlightProvider) (const :tag "List symbols in buffer" :documentSymbolProvider) @@ -1745,9 +1787,9 @@ and just return it. PROMPT shouldn't end with a question mark." (defun eglot--plist-keys (plist) "Get keys of a plist." (cl-loop for (k _v) on plist by #'cddr collect k)) -(defun eglot--ensure-list (x) (if (listp x) x (list x))) -(when (fboundp 'ensure-list) ; Emacs 28 or later - (define-obsolete-function-alias 'eglot--ensure-list #'ensure-list "29.1")) +(defalias 'eglot--ensure-list + (if (fboundp 'ensure-list) #'ensure-list + (lambda (x) (if (listp x) x (list x))))) ;;; Minor modes @@ -1828,6 +1870,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) @@ -1859,6 +1903,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) @@ -1882,16 +1928,16 @@ 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-inlay-hints-mode -1) (eglot--managed-mode -1)) (defun eglot-current-server () "Return logical Eglot server for current buffer, nil if none." (setq eglot--cached-server (or eglot--cached-server - (cl-find major-mode - (gethash (eglot--current-project) eglot--servers-by-project) - :key #'eglot--major-modes - :test #'memq) + (cl-find-if #'eglot--languageId + (gethash (eglot--current-project) + eglot--servers-by-project)) (and eglot-extend-to-xref buffer-file-name (gethash (expand-file-name buffer-file-name) @@ -1940,8 +1986,8 @@ If it is activated, also signal textDocument/didOpen." (put 'eglot--mode-line-format 'risky-local-variable t) -(defun eglot--mouse-call (what) - "Make an interactive lambda for calling WHAT from mode-line." +(defun eglot--mouse-call (what &optional update-mode-line) + "Make an interactive lambda for calling WHAT with the mouse." (lambda (event) (interactive "e") (let ((start (event-start event))) (with-selected-window (posn-window start) @@ -1949,12 +1995,23 @@ If it is activated, also signal textDocument/didOpen." (goto-char (or (posn-point start) (point))) (call-interactively what) - (force-mode-line-update t)))))) + (when update-mode-line + (force-mode-line-update t))))))) -(defun eglot-manual () "Open documentation." - (declare (obsolete info "29.1")) +(defun eglot-manual () "Read Eglot's manual." + (declare (obsolete info "1.10")) (interactive) (info "(eglot)")) +;;;###autoload +(defun eglot-update (&rest _) "Update Eglot." + (interactive) + (with-no-warnings + (require 'package) + (unless package-archive-contents (package-refresh-contents)) + (when-let ((existing (cadr (assoc 'eglot package-alist)))) + (package-delete existing t)) + (package-install (cadr (assoc 'eglot package-archive-contents))))) + (easy-menu-define eglot-menu nil "Eglot" `("Eglot" ;; Commands for getting information and customization. @@ -2023,7 +2080,7 @@ Uses THING, FACE, DEFS and PREPEND." (cl-loop with map = (make-sparse-keymap) for (elem . rest) on defs for (key def help) = elem - do (define-key map `[mode-line ,key] (eglot--mouse-call def)) + do (define-key map `[mode-line ,key] (eglot--mouse-call def t)) concat (format "%s: %s" key help) into blurb when rest concat "\n" into blurb finally (return `(:propertize ,thing @@ -2032,7 +2089,7 @@ Uses THING, FACE, DEFS and PREPEND." mouse-face mode-line-highlight)))) (defun eglot--mode-line-format () - "Compose the Eglot's mode-line." + "Compose Eglot's mode-line." (let* ((server (eglot-current-server)) (nick (and server (eglot-project-nickname server))) (pending (and server (hash-table-count @@ -2069,7 +2126,15 @@ Uses THING, FACE, DEFS and PREPEND." '((mouse-3 eglot-forget-pending-continuations "Forget pending continuations")) "Number of outgoing, \ -still unanswered LSP requests to the server\n")))))))) +still unanswered LSP requests to the server\n"))) + ,@(cl-loop for pr hash-values of (eglot--progress-reporters server) + when (eq (car pr) 'eglot--mode-line-reporter) + append `("/" ,(eglot--mode-line-props + (format "%s%%%%" (or (nth 4 pr) "?")) + 'eglot-mode-line + nil + (format "(%s) %s %s" (nth 1 pr) + (nth 2 pr) (nth 3 pr)))))))))) (add-to-list 'mode-line-misc-info `(eglot--managed-mode (" [" eglot--mode-line-format "] "))) @@ -2084,15 +2149,18 @@ still unanswered LSP requests to the server\n")))))))) (defalias 'eglot--make-diag 'flymake-make-diagnostic) (defalias 'eglot--diag-data 'flymake-diagnostic-data) +(defvar eglot-diagnostics-map + (let ((map (make-sparse-keymap))) + (define-key map [mouse-2] 'eglot-code-actions-at-mouse) + map) + "Keymap active in Eglot-backed Flymake diagnostic overlays.") + (cl-loop for i from 1 for type in '(eglot-note eglot-warning eglot-error) do (put type 'flymake-overlay-control `((mouse-face . highlight) (priority . ,(+ 50 i)) - (keymap . ,(let ((map (make-sparse-keymap))) - (define-key map [mouse-1] - (eglot--mouse-call 'eglot-code-actions)) - map))))) + (keymap . ,eglot-diagnostics-map)))) ;;; Protocol implementation (Requests, notifications, etc) @@ -2114,8 +2182,8 @@ still unanswered LSP requests to the server\n")))))))) (server command arguments) "Execute COMMAND on SERVER with `:workspace/executeCommand'. COMMAND is a symbol naming the command." - (jsonrpc-request server :workspace/executeCommand - `(:command ,(format "%s" command) :arguments ,arguments))) + (eglot--request server :workspace/executeCommand + `(:command ,(format "%s" command) :arguments ,arguments))) (cl-defmethod eglot-handle-notification (_server (_method (eql window/showMessage)) &key type message) @@ -2125,13 +2193,14 @@ COMMAND is a symbol naming the command." type message)) (cl-defmethod eglot-handle-request - (_server (_method (eql window/showMessageRequest)) &key type message actions) + (_server (_method (eql window/showMessageRequest)) + &key type message actions &allow-other-keys) "Handle server request window/showMessageRequest." (let* ((actions (append actions nil)) ;; gh#627 (label (completing-read (concat (format (propertize "[eglot] Server reports (type=%s): %s" - 'face (if (<= type 1) 'error)) + 'face (if (or (not type) (<= type 1)) 'error)) type message) "\nChoose an option: ") (or (mapcar (lambda (obj) (plist-get obj :title)) actions) @@ -2155,22 +2224,31 @@ COMMAND is a symbol naming the command." (server (_method (eql $/progress)) &key token value) "Handle $/progress notification identified by TOKEN from SERVER." (when eglot-report-progress - (cl-flet ((fmt (&rest args) (mapconcat #'identity args " "))) + (cl-flet ((fmt (&rest args) (mapconcat #'identity args " ")) + (mkpr (title) + (if (eq eglot-report-progress 'messages) + (make-progress-reporter + (format "[eglot] %s %s: %s" + (eglot-project-nickname server) token title)) + (list 'eglot--mode-line-reporter token title))) + (upd (pcnt msg &optional + (pr (gethash token (eglot--progress-reporters server)))) + (cond + ((eq (car pr) 'eglot--mode-line-reporter) + (setcdr (cddr pr) (list msg pcnt)) + (force-mode-line-update t)) + (pr (eglot--reporter-update pr pcnt msg))))) (eglot--dbind ((WorkDoneProgress) kind title percentage message) value (pcase kind ("begin" - (let* ((prefix (format (concat "[eglot] %s %s:" (when percentage " ")) - (eglot-project-nickname server) token)) - (pr (puthash token - (if percentage - (make-progress-reporter prefix 0 100 percentage 1 0) - (make-progress-reporter prefix nil nil nil 1 0)) - (eglot--progress-reporters server)))) - (eglot--reporter-update pr percentage (fmt title message)))) - ("report" - (when-let ((pr (gethash token (eglot--progress-reporters server)))) - (eglot--reporter-update pr percentage (fmt title message)))) - ("end" (remhash token (eglot--progress-reporters server)))))))) + (upd percentage (fmt title message) + (puthash token (mkpr title) + (eglot--progress-reporters server)))) + ("report" (upd percentage message)) + ("end" (upd (or percentage 100) message) + (run-at-time 2 nil + (lambda () + (remhash token (eglot--progress-reporters server)))))))))) (cl-defmethod eglot-handle-notification (_server (_method (eql textDocument/publishDiagnostics)) &key uri diagnostics @@ -2291,12 +2369,20 @@ THINGS are either registrations or unregisterations (sic)." (append (eglot--TextDocumentIdentifier) `(:version ,eglot--versioned-identifier))) +(cl-defun eglot--languageId (&optional (server (eglot--current-server-or-lose))) + "Compute LSP \\='languageId\\=' string for current buffer. +Doubles as an predicate telling if SERVER can manage current +buffer." + (cl-loop for (mode . languageid) in + (eglot--languages server) + when (provided-mode-derived-p major-mode mode) + return languageid)) + (defun eglot--TextDocumentItem () "Compute TextDocumentItem object for current buffer." (append (eglot--VersionedTextDocumentIdentifier) - (list :languageId - (eglot--language-id (eglot--current-server-or-lose)) + (list :languageId (eglot--languageId) :text (eglot--widening (buffer-substring-no-properties (point-min) (point-max)))))) @@ -2353,7 +2439,6 @@ 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 @@ -2373,25 +2458,23 @@ THINGS are either registrations or unregisterations (sic)." "Hook onto `after-change-functions'. Records BEG, END and PRE-CHANGE-LENGTH locally." (cl-incf eglot--versioned-identifier) - (pcase (and (listp eglot--recent-changes) - (car eglot--recent-changes)) + (pcase (car-safe eglot--recent-changes) (`(,lsp-beg ,lsp-end (,b-beg . ,b-beg-marker) (,b-end . ,b-end-marker)) - ;; github#259 and github#367: With `capitalize-word' or somesuch, - ;; `before-change-functions' always records the whole word's - ;; `b-beg' and `b-end'. Similarly, when coalescing two lines - ;; into one, `fill-paragraph' they mark the end of the first line - ;; up to the end of the second line. In both situations, args - ;; received here contradict that information: `beg' and `end' - ;; will differ by 1 and will likely only encompass the letter - ;; that was capitalized or, in the sentence-joining situation, - ;; the replacement of the newline with a space. That's we keep - ;; markers _and_ positions so we're able to detect and correct - ;; this. We ignore `beg', `len' and `pre-change-len' and send - ;; "fuller" information about the region from the markers. I've - ;; also experimented with doing this unconditionally but it seems - ;; to break when newlines are added. + ;; github#259 and github#367: with `capitalize-word' & friends, + ;; `before-change-functions' records the whole word's `b-beg' and + ;; `b-end'. Similarly, when `fill-paragraph' coalesces two + ;; lines, `b-beg' and `b-end' mark end of first line and end of + ;; second line, resp. In both situations, `beg' and `end' + ;; received here seemingly contradict that: they will differ by 1 + ;; and encompass the capitalized character or, in the coalescing + ;; case, the replacement of the newline with a space. We keep + ;; both markers and positions to detect and correct this. In + ;; this specific case, we ignore `beg', `len' and + ;; `pre-change-len' and send richer information about the region + ;; from the markers. I've also experimented with doing this + ;; unconditionally but it seems to break when newlines are added. (if (and (= b-end b-end-marker) (= b-beg b-beg-marker) (or (/= beg b-beg) (/= end b-end))) (setcar eglot--recent-changes @@ -2412,16 +2495,6 @@ Records BEG, END and PRE-CHANGE-LENGTH locally." (run-hooks 'eglot--document-changed-hook) (setq eglot--change-idle-timer nil)))))))) -;; HACK! Launching a deferred sync request with outstanding changes is a -;; bad idea, since that might lead to the request never having a -;; chance to run, because `jsonrpc-connection-ready-p'. -(advice-add #'jsonrpc-request :before - (cl-function (lambda (_proc _method _params &key - deferred &allow-other-keys) - (when (and eglot--managed-mode deferred) - (eglot--signal-textDocument/didChange)))) - '((name . eglot--signal-textDocument/didChange))) - (defvar-local eglot-workspace-configuration () "Configure LSP servers specifically for a given project. @@ -2479,7 +2552,7 @@ use the root of SERVER's `eglot--project'." ;; Set the major mode to be the first of the managed ;; modes. This is the one the user started eglot in. (setq major-mode (car (eglot--major-modes server))) - (hack-dir-local-variables-non-file-buffer)() + (hack-dir-local-variables-non-file-buffer) (if (functionp eglot-workspace-configuration) (funcall eglot-workspace-configuration server) eglot-workspace-configuration)))) @@ -2574,8 +2647,8 @@ When called interactively, use the currently active server" (when (eglot--server-capable :textDocumentSync :willSaveWaitUntil) (ignore-errors (eglot--apply-text-edits - (jsonrpc-request server :textDocument/willSaveWaitUntil params - :timeout 0.5)))))) + (eglot--request server :textDocument/willSaveWaitUntil params + :timeout 0.5)))))) (defun eglot--signal-textDocument/didSave () "Maybe send textDocument/didSave to server." @@ -2687,8 +2760,8 @@ If BUFFER, switch to it before." (propertize (alist-get kind eglot--symbol-kind-names "Unknown") 'face 'shadow)) 'eglot--lsp-workspaceSymbol wss))) - (jsonrpc-request (eglot--current-server-or-lose) :workspace/symbol - `(:query ,pat))))) + (eglot--request (eglot--current-server-or-lose) :workspace/symbol + `(:query ,pat))))) (cl-defmethod xref-backend-identifier-completion-table ((_backend (eql eglot))) "Yet another tricky connection between LSP and Elisp completion semantics." @@ -2744,7 +2817,7 @@ If BUFFER, switch to it before." (cadr (split-string (symbol-name method) "/")))))) (let ((response - (jsonrpc-request + (eglot--request (eglot--current-server-or-lose) method (append (eglot--TextDocumentPositionParams) extra-params)))) (eglot--collecting-xrefs (collect) @@ -2807,9 +2880,9 @@ If BUFFER, switch to it before." (eglot--lambda ((SymbolInformation) name location) (eglot--dbind ((Location) uri range) location (collect (eglot--xref-make-match name uri range)))) - (jsonrpc-request (eglot--current-server-or-lose) - :workspace/symbol - `(:query ,pattern)))))) + (eglot--request (eglot--current-server-or-lose) + :workspace/symbol + `(:query ,pattern)))))) (defun eglot-format-buffer () "Format contents of current buffer." @@ -2841,7 +2914,7 @@ for which LSP on-type-formatting should be requested." '(:textDocument/formatting :documentFormattingProvider nil))))) (eglot--server-capable-or-lose cap) (eglot--apply-text-edits - (jsonrpc-request + (eglot--request (eglot--current-server-or-lose) method (cl-list* @@ -2850,8 +2923,14 @@ for which LSP on-type-formatting should be requested." :insertSpaces (if indent-tabs-mode :json-false t) :insertFinalNewline (if require-final-newline t :json-false) :trimFinalNewlines (if delete-trailing-lines t :json-false)) - args) - :deferred method)))) + 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." @@ -2868,42 +2947,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 - (jsonrpc-request server - :textDocument/completion - (eglot--CompletionParams) - :deferred :textDocument/completion - :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 (and (eql insertTextFormat 2) - (eglot--snippet-expansion-fn)) - 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, @@ -2914,13 +3001,21 @@ for which LSP on-type-formatting should be requested." (if (and (eglot--server-capable :completionProvider :resolveProvider) (plist-get lsp-comp :data)) - (jsonrpc-request server :completionItem/resolve - lsp-comp :cancel-on-input t) - lsp-comp))))) - (bounds (bounds-of-thing-at-point 'symbol))) + (eglot--request server :completionItem/resolve + lsp-comp :cancel-on-input t) + 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 @@ -2991,7 +3086,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 @@ -2999,6 +3094,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 @@ -3022,17 +3118,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))) @@ -3048,69 +3139,62 @@ for which LSP on-type-formatting should be requested." (funcall snippet-fn (or insertText label)))) (when (cl-plusp (length additionalTextEdits)) (eglot--apply-text-edits additionalTextEdits))) - (eglot--signal-textDocument/didChange) - (eldoc))))))))) + (eglot--signal-textDocument/didChange))))))))) (defun eglot--hover-info (contents &optional _range) (mapconcat #'eglot--format-markup (if (vectorp contents) contents (list contents)) "\n")) -(defun eglot--sig-info (sigs active-sig sig-help-active-param) - (cl-loop - for (sig . moresigs) on (append sigs nil) for i from 0 - concat - (eglot--dbind ((SignatureInformation) label documentation parameters activeParameter) sig - (with-temp-buffer - (save-excursion (insert label)) - (let ((active-param (or activeParameter sig-help-active-param)) - params-start params-end) - ;; Ad-hoc attempt to parse label as () - (when (looking-at "\\([^(]*\\)(\\([^)]+\\))") - (setq params-start (match-beginning 2) params-end (match-end 2)) - (add-face-text-property (match-beginning 1) (match-end 1) - 'font-lock-function-name-face)) - (when (eql i active-sig) - ;; Decide whether to add one-line-summary to signature line - (when (and (stringp documentation) - (string-match "[[:space:]]*\\([^.\r\n]+[.]?\\)" - documentation)) - (setq documentation (match-string 1 documentation)) - (unless (string-prefix-p (string-trim documentation) label) - (goto-char (point-max)) - (insert ": " (eglot--format-markup documentation)))) - ;; Decide what to do with the active parameter... - (when (and (eql i active-sig) active-param - (< -1 active-param (length parameters))) - (eglot--dbind ((ParameterInformation) label documentation) - (aref parameters active-param) - ;; ...perhaps highlight it in the formals list - (when params-start - (goto-char params-start) - (pcase-let - ((`(,beg ,end) - (if (stringp label) - (let ((case-fold-search nil)) - (and (re-search-forward - (concat "\\<" (regexp-quote label) "\\>") - params-end t) - (list (match-beginning 0) (match-end 0)))) - (mapcar #'1+ (append label nil))))) - (if (and beg end) - (add-face-text-property - beg end - 'eldoc-highlight-function-argument)))) - ;; ...and/or maybe add its doc on a line by its own. - (when documentation - (goto-char (point-max)) - (insert "\n" - (propertize - (if (stringp label) - label - (apply #'buffer-substring (mapcar #'1+ label))) - 'face 'eldoc-highlight-function-argument) - ": " (eglot--format-markup documentation)))))) - (buffer-string)))) - when moresigs concat "\n")) +(defun eglot--sig-info (sig &optional sig-active briefp) + (eglot--dbind ((SignatureInformation) + ((:label siglabel)) + ((:documentation sigdoc)) parameters activeParameter) + sig + (with-temp-buffer + (save-excursion (insert siglabel)) + ;; Ad-hoc attempt to parse label as () + (when (looking-at "\\([^(]*\\)(\\([^)]+\\))") + (add-face-text-property (match-beginning 1) (match-end 1) + 'font-lock-function-name-face)) + ;; Add documentation, indented so we can distinguish multiple signatures + (when-let (doc (and (not briefp) sigdoc (eglot--format-markup sigdoc))) + (goto-char (point-max)) + (insert "\n" (replace-regexp-in-string "^" " " doc))) + ;; Now to the parameters + (cl-loop + with active-param = (or sig-active activeParameter) + for i from 0 for parameter across parameters do + (eglot--dbind ((ParameterInformation) + ((:label parlabel)) + ((:documentation pardoc))) + parameter + ;; ...perhaps highlight it in the formals list + (when (and (eq i active-param)) + (save-excursion + (goto-char (point-min)) + (pcase-let + ((`(,beg ,end) + (if (stringp parlabel) + (let ((case-fold-search nil)) + (and (search-forward parlabel (line-end-position) t) + (list (match-beginning 0) (match-end 0)))) + (mapcar #'1+ (append parlabel nil))))) + (if (and beg end) + (add-face-text-property + beg end + 'eldoc-highlight-function-argument))))) + ;; ...and/or maybe add its doc on a line by its own. + (let (fpardoc) + (when (and pardoc (not briefp) + (not (string-empty-p + (setq fpardoc (eglot--format-markup pardoc))))) + (insert "\n " + (propertize + (if (stringp parlabel) parlabel + (apply #'substring siglabel (mapcar #'1+ parlabel))) + 'face (and (eq i active-param) 'eldoc-highlight-function-argument)) + ": " fpardoc))))) + (buffer-string)))) (defun eglot-signature-eldoc-function (cb) "A member of `eldoc-documentation-functions', for signatures." @@ -3121,13 +3205,18 @@ for which LSP on-type-formatting should be requested." :textDocument/signatureHelp (eglot--TextDocumentPositionParams) :success-fn (eglot--lambda ((SignatureHelp) - signatures activeSignature activeParameter) + signatures activeSignature (activeParameter 0)) (eglot--when-buffer-window buf - (funcall cb - (unless (seq-empty-p signatures) - (eglot--sig-info signatures - activeSignature - activeParameter))))) + (let ((active-sig (and (cl-plusp (length signatures)) + (aref signatures (or activeSignature 0))))) + (if (not active-sig) (funcall cb nil) + (funcall + cb (mapconcat (lambda (s) + (eglot--sig-info s (and (eq s active-sig) + activeParameter) + nil)) + signatures "\n") + :echo (eglot--sig-info active-sig activeParameter t)))))) :deferred :textDocument/signatureHelp)) t)) @@ -3142,7 +3231,8 @@ for which LSP on-type-formatting should be requested." (eglot--when-buffer-window buf (let ((info (unless (seq-empty-p contents) (eglot--hover-info contents range)))) - (funcall cb info :buffer t)))) + (funcall cb info + :echo (and info (string-match "\n" info)))))) :deferred :textDocument/hover)) (eglot--highlight-piggyback cb) t)) @@ -3176,52 +3266,60 @@ for which LSP on-type-formatting should be requested." :deferred :textDocument/documentHighlight) nil))) +(defun eglot--imenu-SymbolInformation (res) + "Compute `imenu--index-alist' for RES vector of SymbolInformation." + (mapcar + (pcase-lambda (`(,kind . ,objs)) + (cons + (alist-get kind eglot--symbol-kind-names "Unknown") + (mapcan + (pcase-lambda (`(,container . ,objs)) + (let ((elems (mapcar + (eglot--lambda ((SymbolInformation) kind name location) + (let ((reg (eglot--range-region + (plist-get location :range))) + (kind (alist-get kind eglot--symbol-kind-names))) + (cons (propertize name + 'breadcrumb-region reg + 'breadcrumb-kind kind) + (car reg)))) + objs))) + (if container (list (cons container elems)) elems))) + (seq-group-by + (eglot--lambda ((SymbolInformation) containerName) containerName) objs)))) + (seq-group-by (eglot--lambda ((SymbolInformation) kind) kind) res))) + +(defun eglot--imenu-DocumentSymbol (res) + "Compute `imenu--index-alist' for RES vector of DocumentSymbol." + (cl-labels ((dfs (&key name children range kind &allow-other-keys) + (let* ((reg (eglot--range-region range)) + (kind (alist-get kind eglot--symbol-kind-names)) + (name (propertize name + 'breadcrumb-region reg + 'breadcrumb-kind kind))) + (if (seq-empty-p children) + (cons name (car reg)) + (cons name + (mapcar (lambda (c) (apply #'dfs c)) children)))))) + (mapcar (lambda (s) (apply #'dfs s)) res))) + (defun eglot-imenu () "Eglot's `imenu-create-index-function'. Returns a list as described in docstring of `imenu--index-alist'." - (cl-labels - ((unfurl (obj) - (eglot--dcase obj - (((SymbolInformation)) (list obj)) - (((DocumentSymbol) name children) - (cons obj - (mapcar - (lambda (c) - (plist-put - c :containerName - (let ((existing (plist-get c :containerName))) - (if existing (format "%s::%s" name existing) - name)))) - (mapcan #'unfurl children))))))) - (mapcar - (pcase-lambda (`(,kind . ,objs)) - (cons - (alist-get kind eglot--symbol-kind-names "Unknown") - (mapcan (pcase-lambda (`(,container . ,objs)) - (let ((elems (mapcar - (lambda (obj) - (cons (plist-get obj :name) - (car (eglot--range-region - (eglot--dcase obj - (((SymbolInformation) location) - (plist-get location :range)) - (((DocumentSymbol) selectionRange) - selectionRange)))))) - objs))) - (if container (list (cons container elems)) elems))) - (seq-group-by - (lambda (e) (plist-get e :containerName)) objs)))) - (seq-group-by - (lambda (obj) (plist-get obj :kind)) - (mapcan #'unfurl - (jsonrpc-request (eglot--current-server-or-lose) - :textDocument/documentSymbol - `(:textDocument - ,(eglot--TextDocumentIdentifier)) - :cancel-on-input non-essential)))))) - -(defun eglot--apply-text-edits (edits &optional version) + (let* ((res (eglot--request (eglot--current-server-or-lose) + :textDocument/documentSymbol + `(:textDocument + ,(eglot--TextDocumentIdentifier)) + :cancel-on-input non-essential)) + (head (and (cl-plusp (length res)) (elt res 0)))) + (when head + (eglot--dcase head + (((SymbolInformation)) (eglot--imenu-SymbolInformation res)) + (((DocumentSymbol)) (eglot--imenu-DocumentSymbol res)))))) + +(cl-defun eglot--apply-text-edits (edits &optional version) "Apply EDITS for current buffer if at VERSION, or if it's nil." + (unless edits (cl-return-from eglot--apply-text-edits)) (unless (or (not version) (equal version eglot--versioned-identifier)) (jsonrpc-error "Edits on `%s' require version %d, you have %d" (current-buffer) version eglot--versioned-identifier)) @@ -3243,7 +3341,7 @@ Returns a list as described in docstring of `imenu--index-alist'." (save-restriction (narrow-to-region beg end) (replace-buffer-contents temp))) - (progress-reporter-update reporter (cl-incf done))))))) + (eglot--reporter-update reporter (cl-incf done))))))) (mapcar (eglot--lambda ((TextEdit) range newText) (cons newText (eglot--range-region range 'markers))) (reverse edits))) @@ -3288,9 +3386,9 @@ Returns a list as described in docstring of `imenu--index-alist'." (symbol-name (symbol-at-point))))) (eglot--server-capable-or-lose :renameProvider) (eglot--apply-workspace-edit - (jsonrpc-request (eglot--current-server-or-lose) - :textDocument/rename `(,@(eglot--TextDocumentPositionParams) - :newName ,newname)) + (eglot--request (eglot--current-server-or-lose) + :textDocument/rename `(,@(eglot--TextDocumentPositionParams) + :newName ,newname)) current-prefix-arg)) (defun eglot--region-bounds () @@ -3316,7 +3414,7 @@ at point. With prefix argument, prompt for ACTION-KIND." (eglot--server-capable-or-lose :codeActionProvider) (let* ((server (eglot--current-server-or-lose)) (actions - (jsonrpc-request + (eglot--request server :textDocument/codeAction (list :textDocument (eglot--TextDocumentIdentifier) @@ -3328,8 +3426,7 @@ at point. With prefix argument, prompt for ACTION-KIND." when (cdr (assoc 'eglot-lsp-diag (eglot--diag-data diag))) collect it)] - ,@(when action-kind `(:only [,action-kind])))) - :deferred t)) + ,@(when action-kind `(:only [,action-kind])))))) ;; Redo filtering, in case the `:only' didn't go through. (actions (cl-loop for a across actions when (or (not action-kind) @@ -3339,6 +3436,9 @@ at point. With prefix argument, prompt for ACTION-KIND." (eglot--read-execute-code-action actions server action-kind) actions))) +(defalias 'eglot-code-actions-at-mouse (eglot--mouse-call 'eglot-code-actions) + "Like `eglot-code-actions', but intended for mouse events.") + (defun eglot--read-execute-code-action (actions server &optional action-kind) "Helper for interactive calls to `eglot-code-actions'." (let* ((menu-items @@ -3376,7 +3476,7 @@ at point. With prefix argument, prompt for ACTION-KIND." `(defun ,name (beg &optional end) ,(format "Execute `%s' code actions between BEG and END." kind) (interactive (eglot--region-bounds)) - (eglot-code-actions beg end ,kind))) + (eglot-code-actions beg end ,kind t))) (eglot--code-action eglot-code-action-organize-imports "source.organizeImports") (eglot--code-action eglot-code-action-extract "refactor.extract") @@ -3427,8 +3527,9 @@ at point. With prefix argument, prompt for ACTION-KIND." (unwind-protect (progn (dolist (dir dirs-to-watch) - (push (file-notify-add-watch dir '(change) #'handle-event) - (gethash id (eglot--file-watches server)))) + (when (file-readable-p dir) + (push (file-notify-add-watch dir '(change) #'handle-event) + (gethash id (eglot--file-watches server))))) (setq success `(:message ,(format "OK, watching %s directories in %s watchers" @@ -3607,31 +3708,41 @@ If NOERROR, return predicate, else erroring function." (eglot--lambda ((InlayHint) position kind label paddingLeft paddingRight) (goto-char (eglot--lsp-position-to-point position)) (when (or (> (point) to) (< (point) from)) (cl-return)) - (let ((left-pad (and paddingLeft - (not (eq paddingLeft :json-false)) - (not (memq (char-before) '(32 9))) " ")) - (right-pad (and paddingRight - (not (eq paddingRight :json-false)) - (not (memq (char-after) '(32 9))) " "))) - (cl-flet - ((do-it (text lpad rpad) - (let ((ov (make-overlay (point) (point)))) - (overlay-put ov 'before-string + (let* ((left-pad (and paddingLeft + (not (eq paddingLeft :json-false)) + (not (memq (char-before) '(32 9))) " ")) + (right-pad (and paddingRight + (not (eq paddingRight :json-false)) + (not (memq (char-after) '(32 9))) " ")) + (peg-after-p (eql kind 1))) + (cl-labels + ((make-ov () + (if peg-after-p + (make-overlay (point) (1+ (point)) nil t) + (make-overlay (1- (point)) (point) nil nil nil))) + (do-it (label lpad rpad firstp) + (let* ((tweak-cursor-p (and firstp peg-after-p)) + (ov (make-ov)) + (text (concat lpad label rpad))) + (when tweak-cursor-p (put-text-property 0 1 'cursor 1 text)) + (overlay-put ov (if peg-after-p 'before-string 'after-string) (propertize - (concat lpad text rpad) + text '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 'evaporate t) (overlay-put ov 'eglot--overlay t)))) - (if (stringp label) (do-it label left-pad right-pad) + (if (stringp label) (do-it label left-pad right-pad t) (cl-loop for i from 0 for ldetail across label do (eglot--dbind ((InlayHintLabelPart) value) ldetail (do-it value (and (zerop i) left-pad) - (and (= i (1- (length label))) right-pad)))))))))) + (and (= i (1- (length label))) right-pad) + (zerop i)))))))))) (jsonrpc-async-request (eglot--current-server-or-lose) :textDocument/inlayHint @@ -3641,7 +3752,19 @@ If NOERROR, return predicate, else erroring function." :success-fn (lambda (hints) (eglot--when-live-buffer buf (eglot--widening - (remove-overlays from to 'eglot--inlay-hint t) + ;; Overlays ending right at FROM with an + ;; `after-string' property logically belong to + ;; the (FROM TO) region. Likewise, such + ;; overlays ending at TO don't logically belong + ;; to it. + (dolist (o (overlays-in (1- from) to)) + (when (and (overlay-get o 'eglot--inlay-hint) + (cond ((eq (overlay-end o) from) + (overlay-get o 'after-string)) + ((eq (overlay-end o) to) + (overlay-get o 'before-string)) + (t))) + (delete-overlay o))) (mapc paint-hint hints)))) :deferred 'eglot--update-hints-1))) @@ -3659,13 +3782,11 @@ If NOERROR, return predicate, else erroring function." ;;; Hacks ;;; -;; FIXME: Although desktop.el compatibility is Emacs bug#56407, the -;; optimal solution agreed to there is a bit more work than what I -;; have time to right now. See -;; e.g. https://debbugs.gnu.org/cgi/bugreport.cgi?bug=bug%2356407#68. -;; For now, just use `with-eval-after-load' +;; Emacs bug#56407, the optimal solution is in desktop.el, but that's +;; 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--managed-mode . ignore)) + (add-to-list 'desktop-minor-mode-handlers '(eglot-inlay-hints-mode . ignore))) ;;; Misc