From a972d480b71ff6bfab53f24f8223118e66dcb4fb Mon Sep 17 00:00:00 2001 From: Jimmy Yuen Ho Wong Date: Thu, 31 Aug 2023 00:49:57 +0100 Subject: [PATCH] Eglot support --- .github/workflows/ci.yml | 60 ++++++++ .github/workflows/test.yml | 64 --------- Cask | 2 + README.rst | 20 ++- pet.el | 154 ++++++++++++++++++++- test/pet-test.el | 275 ++++++++++++++++++++++++++++++++++++- 6 files changed, 503 insertions(+), 72 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..688dfd5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: "CI" +on: + push: + paths-ignore: + - "README.rst" + branches: + - "main" + pull_request: + branches: + - "*" + +jobs: + ci: + runs-on: "ubuntu-latest" + continue-on-error: "${{ matrix.experimental }}" + strategy: + fail-fast: false + matrix: + emacs-version: + - "26.1" + - "26.2" + - "26.3" + - "27.1" + - "27.2" + - "28.1" + - "28.2" + - "29.1" + experimental: [false] + include: + - emacs-version: "snapshot" + experimental: true + + steps: + - uses: "actions/checkout@v4" + + - uses: "purcell/setup-emacs@master" + with: + version: "${{ matrix.emacs-version }}" + + - uses: "cask/setup-cask@master" + with: + version: "0.9.0" + + - name: "Compile" + run: | + make compile + + - uses: "actions/setup-go@v4" + with: + go-version: "stable" + check-latest: true + cache: false + + - name: "Install tomljson" + run: | + go install github.com/pelletier/go-toml/v2/cmd/tomljson@latest + + - name: "Test" + run: | + make test diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index da04dcc..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,64 +0,0 @@ -on: - push: - branches: - - "*" - pull_request: - branches: - - "*" -jobs: - compile: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - emacs-version: - - "26.1" - - "26.2" - - "26.3" - - "27.1" - - "27.2" - - "28.1" - - "29.1" - - "snapshot" - steps: - - uses: actions/checkout@v3 - - uses: purcell/setup-emacs@master - with: - version: ${{ matrix.emacs-version }} - - uses: cask/setup-cask@master - with: - version: 0.9.0 - - run: make compile - - test: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - emacs-version: - - "26.1" - - "26.2" - - "26.3" - - "27.1" - - "27.2" - - "28.1" - - "29.1" - - "snapshot" - steps: - - uses: actions/checkout@v3 - - uses: purcell/setup-emacs@master - with: - version: ${{ matrix.emacs-version }} - - uses: cask/setup-cask@master - with: - version: snapshot - - uses: actions/setup-go@v4 - with: - go-version: 'stable' - check-latest: true - - name: Install packages - run: | - go install github.com/pelletier/go-toml/v2/cmd/tomljson@latest - - name: Run tests - run: | - make test diff --git a/Cask b/Cask index b51fb27..37f70c1 100644 --- a/Cask +++ b/Cask @@ -2,6 +2,8 @@ (source melpa) (depends-on "f" "0.6.0") +(depends-on "map" "3.3.1") +(depends-on "seq" "2.24") (development (depends-on "buttercup") diff --git a/README.rst b/README.rst index e658df2..cf3e528 100644 --- a/README.rst +++ b/README.rst @@ -66,6 +66,7 @@ Supported Emacs Packages - Built-in `project.el `_ - `projectile `_ - `envrc `_ (`direnv caveats`_) +- `eglot `_ - `flycheck `_ - `lsp-jedi `_ - `lsp-pyright `_ @@ -196,6 +197,8 @@ Complete Example (use-package dap-python :after lsp) + (use-package eglot) + (use-package python-pytest) (use-package python-black) @@ -210,6 +213,9 @@ Complete Example (setq-local python-shell-interpreter (pet-executable-find "python") python-shell-virtualenv-root (pet-virtualenv-root)) + ;; (pet-eglot-setup) + ;; (eglot-ensure) + (pet-flycheck-setup) (flycheck-mode) @@ -305,12 +311,18 @@ setting the corresponding ``flycheck`` checker executable variable to the intended absolute path. -``pet`` can't find my virtualenvs, how do I debug it? -+++++++++++++++++++++++++++++++++++++++++++++++++++++ +My package didn't pick up the correct paths, how do I debug ``pet``? ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ You can turn on ``pet-debug`` and watch what comes out in the ``*Messages*`` -buffer. In addition, you can use ``M-x pet-verify-setup`` in your Python -buffers to find out what was detected. +buffer. In addition, you can use ``M-x pet-verify-setup`` in your Python buffers +to find out what was detected. + +For ``lsp``, use ``lsp-describe-session``. + +For ``eglot``, use ``eglot-show-workspace-configuration``. + +For ``flycheck``, use ``flycheck-verify-setup``. Do I still need any of the 11+ virtualenv Emacs packages? diff --git a/pet.el b/pet.el index b62c1cf..c7e10f3 100644 --- a/pet.el +++ b/pet.el @@ -3,7 +3,7 @@ ;; Author: Jimmy Yuen Ho Wong ;; Maintainer: Jimmy Yuen Ho Wong ;; Version: 1.1.0 -;; Package-Requires: ((emacs "26.1") (f "0.6.0")) +;; Package-Requires: ((emacs "26.1") (f "0.6.0") (map "3.3.1") (seq "2.24")) ;; Homepage: https://github.com/wyuenho/emacs-pet/ ;; Keywords: tools @@ -37,6 +37,7 @@ (require 'f) (require 'filenotify) (require 'let-alist) +(require 'map) (require 'pcase) (require 'project) (require 'python) @@ -729,6 +730,149 @@ default otherwise." +(defvar eglot-workspace-configuration) +(declare-function jsonrpc--process "ext:jsonrpc") +(declare-function eglot--executable-find "ext:eglot") +(declare-function eglot--uri-to-path "ext:eglot") +(declare-function eglot--workspace-configuration-plist "ext:eglot") +(declare-function eglot--guess-contact "ext:eglot") + +(defun pet-eglot--executable-find-advice (fn &rest args) + "Look up Python language servers using `pet-executable-find'. + +FN is `eglot--executable-find', ARGS is the arguments to +`eglot--executable-find'." + (pcase-let ((`(,command . ,_) args)) + (if (member command '("pylsp" "pyls" "pyright-langserver" "jedi-language-server")) + (pet-executable-find command) + (apply fn args)))) + +(defun pet-lookup-eglot-server-initialization-options (command) + "Return LSP initializationOptions for Eglot. + +COMMAND is the name of the Python language server command." + (cond ((string-match-p "pylsp" command) + `(:pylsp + (:plugins + (:jedi + (:environment + ,(pet-virtualenv-root)) + :flake8 + (:executable + ,(pet-executable-find "flake8")) + :pylint + (:executable + ,(pet-executable-find "pylint")))))) + ((string-match-p "pyls" command) + `(:pyls + (:plugins + (:jedi + (:environment + ,(pet-virtualenv-root)) + :pylint + (:executable + ,(pet-executable-find "pylint")))))) + ((string-match-p "pyright-langserver" command) + `(:python + (:pythonPath + ,(pet-executable-find "python") + :venvPath + ,(pet-virtualenv-root)))) + ((string-match-p "jedi-language-server" command) + `(:jedi + (:executable + (:command + ,(pet-executable-find "jedi-language-server")) + :workspace + (:environmentPath + ,(pet-executable-find "python"))))) + (t nil))) + +(defalias 'pet--proper-list-p 'proper-list-p) +(eval-when-compile + (when (and (not (functionp 'proper-list-p)) + (functionp 'format-proper-list-p)) + (defun pet--proper-list-p (l) + (and (format-proper-list-p l) + (length l))))) + +(defun pet--plistp (object) + "Non-nil if and only if OBJECT is a valid plist." + (let ((len (pet--proper-list-p object))) + (and len + (zerop (% len 2)) + (seq-every-p + (lambda (kvp) + (keywordp (car kvp))) + (seq-split object 2))))) + +(defun pet-merge-eglot-initialization-options (a b) + "Deep merge plists A and B." + (map-merge-with 'plist + (lambda (c d) + (cond ((and (pet--plistp c) (pet--plistp d)) + (pet-merge-eglot-initialization-options c d)) + ((and (vectorp c) (vectorp d)) + (vconcat (seq-union c d))) + (t d))) + (copy-tree a t) + (copy-tree b t))) + +(defun pet-eglot--workspace-configuration-plist-advice (fn &rest args) + "Enrich `eglot-workspace-configuration' with paths found by `pet'. + +FN is `eglot--workspace-configuration-plist', ARGS is the +arguments to `eglot--workspace-configuration-plist'." + (let* ((path (cadr args)) + (canonical-path (if (and path (file-directory-p path)) + (file-name-as-directory path) + path)) + (server (car args)) + (command (process-command (jsonrpc--process server))) + (program (and (listp command) (car command))) + (pet-config (pet-lookup-eglot-server-initialization-options program)) + (user-config (apply fn server (and canonical-path (cons canonical-path (cddr args)))))) + (pet-merge-eglot-initialization-options user-config pet-config))) + +(defun pet-eglot--guess-contact-advice (fn &rest args) + "Enrich `eglot--guess-contact' with paths found by `pet'. + +FN is `eglot--guess-contact', ARGS is the arguments to +`eglot--guess-contact'." + (let* ((result (apply fn args)) + (contact (nth 3 result)) + (probe (seq-position contact :initializationOptions)) + (program-with-args (seq-subseq contact 0 (or probe (length contact)))) + (program (car program-with-args)) + (init-opts (plist-get (seq-subseq contact (or probe 0)) :initializationOptions))) + (if init-opts + (append (seq-subseq result 0 3) + (list + (append + program-with-args + (list + :initializationOptions + (pet-merge-eglot-initialization-options + init-opts + (pet-lookup-eglot-server-initialization-options + program))))) + (seq-subseq result 4)) + result))) + +(defun pet-eglot-setup () + "Setup Eglot to use server executables and virtualenvs found by PET." + (advice-add 'eglot--executable-find :around #'pet-eglot--executable-find-advice) + (advice-add 'eglot--workspace-configuration-plist :around #'pet-eglot--workspace-configuration-plist-advice) + (advice-add 'eglot--guess-contact :around #'pet-eglot--guess-contact-advice)) + +(defun pet-eglot-teardown () + "Setup PET advices to Eglot." + (advice-remove 'eglot--executable-find #'pet-eglot--executable-find-advice) + (advice-remove 'eglot--workspace-configuration-plist #'pet-eglot--workspace-configuration-plist-advice) + (advice-remove 'eglot--guess-contact #'pet-eglot--guess-contact-advice)) + + + (defvar lsp-jedi-executable-command) (defvar lsp-pyls-plugins-jedi-environment) (defvar lsp-pylsp-plugins-jedi-environment) @@ -762,7 +906,9 @@ buffer local values." (setq-local python-black-command (pet-executable-find "black")) (setq-local python-isort-command (pet-executable-find "isort")) (setq-local blacken-executable python-black-command) - (setq-local yapfify-executable (pet-executable-find "yapf"))) + (setq-local yapfify-executable (pet-executable-find "yapf")) + + (pet-eglot-setup)) (defun pet-buffer-local-vars-teardown () "Reset all supported buffer local variable values to default." @@ -782,7 +928,9 @@ buffer local values." (kill-local-variable 'python-black-command) (kill-local-variable 'python-isort-command) (kill-local-variable 'blacken-executable) - (kill-local-variable 'yapfify-executable)) + (kill-local-variable 'yapfify-executable) + + (pet-eglot-teardown)) (defun pet-verify-setup () "Verify the values of buffer local variables visually. diff --git a/test/pet-test.el b/test/pet-test.el index ad32cd8..9d2d431 100644 --- a/test/pet-test.el +++ b/test/pet-test.el @@ -59,7 +59,7 @@ (it "should find project root with `project.el'" (spy-on 'projectile-project-root) - (spy-on 'project-current :and-return-value (if (< emacs-major-version 29) '(vc . "/") '(vc Git "/"))) + (spy-on 'project-current :and-return-value (if (< emacs-major-version 29) (cons 'vc "/") '(vc Git "/"))) (expect (pet-project-root) :to-equal "/")) (it "should return nil when Python file does not appear to be in a project" @@ -962,6 +962,279 @@ (expect (local-variable-p 'flycheck-python-pyright-executable) :not :to-be-truthy) (expect (local-variable-p 'flycheck-python-pycompile-executable) :not :to-be-truthy))) +(describe "pet-eglot--executable-find-advice" + (it "should delegate to `pet-executable-find' for Python LSP servers" + (spy-on 'eglot--executable-find :and-call-fake (lambda (&rest args) (string-join args " "))) + (spy-on 'pet-executable-find :and-call-fake 'identity) + + (expect (pet-eglot--executable-find-advice 'eglot--executable-find "pylsp") :to-equal "pylsp") + (expect (spy-context-return-value (spy-calls-most-recent 'pet-executable-find)) :to-equal "pylsp") + (expect 'eglot--executable-find :not :to-have-been-called) + + (expect (pet-eglot--executable-find-advice 'eglot--executable-find "pyls") :to-equal "pyls") + (expect (spy-context-return-value (spy-calls-most-recent 'pet-executable-find)) :to-equal "pyls") + (expect 'eglot--executable-find :not :to-have-been-called) + + (expect (pet-eglot--executable-find-advice 'eglot--executable-find "pyright-langserver") :to-equal "pyright-langserver") + (expect (spy-context-return-value (spy-calls-most-recent 'pet-executable-find)) :to-equal "pyright-langserver") + (expect 'eglot--executable-find :not :to-have-been-called) + + (expect (pet-eglot--executable-find-advice 'eglot--executable-find "jedi-language-server") :to-equal "jedi-language-server") + (expect (spy-context-return-value (spy-calls-most-recent 'pet-executable-find)) :to-equal "jedi-language-server") + (expect 'eglot--executable-find :not :to-have-been-called) + + (expect (pet-eglot--executable-find-advice 'eglot--executable-find "sh" "-c") :to-equal "sh -c") + (expect 'eglot--executable-find :to-have-been-called-with "sh" "-c"))) + +(describe "pet-eglot--workspace-configuration-plist-advice" + (before-each + (spy-on 'jsonrpc--process)) + + (it "should pass canonicalized PATH to FN if it's a directory" + (spy-on 'mock-eglot--workspace-configuration-plist) + (spy-on 'process-command :and-return-value '("/usr/bin/jedi-language-server")) + (spy-on 'file-directory-p :and-return-value t) + + (pet-eglot--workspace-configuration-plist-advice + 'mock-eglot--workspace-configuration-plist + "server" "/home/users/project") + + (expect 'mock-eglot--workspace-configuration-plist :to-have-been-called-with "server" "/home/users/project/")) + + (it "should pass PATH to FN directly if it's a not directory" + (spy-on 'mock-eglot--workspace-configuration-plist) + (spy-on 'process-command :and-return-value '("/usr/bin/jedi-language-server")) + (spy-on 'file-directory-p :and-return-value nil) + + (pet-eglot--workspace-configuration-plist-advice + 'mock-eglot--workspace-configuration-plist + "server" "/home/users/project/file") + + (expect 'mock-eglot--workspace-configuration-plist :to-have-been-called-with "server" "/home/users/project/file")) + + (it "should return `nil' when no dir local variables and pet server initialization options" + (spy-on 'mock-eglot--workspace-configuration-plist) + (spy-on 'process-command :and-return-value '("/usr/bin/some-lsp-server")) + + (expect (pet-eglot--workspace-configuration-plist-advice + 'mock-eglot--workspace-configuration-plist + "server") + :not :to-be-truthy) + + (expect 'mock-eglot--workspace-configuration-plist :to-have-been-called)) + + (it "should return pet server initialization options when no dir local variables" + (spy-on 'mock-eglot--workspace-configuration-plist) + (spy-on 'process-command :and-return-value '("/usr/bin/pyright-langserver")) + (spy-on 'pet-lookup-eglot-server-initialization-options + :and-return-value + '(:python + (:pythonPath + "/usr/bin/python" + :venvPath + "/home/user/project/"))) + + (expect (pet-eglot--workspace-configuration-plist-advice + 'mock-eglot--workspace-configuration-plist + "server") + :to-equal '(:python + (:pythonPath + "/usr/bin/python" + :venvPath + "/home/user/project/"))) + + (expect 'mock-eglot--workspace-configuration-plist :to-have-been-called)) + + (it "should return dir local variables when pet server initialization options" + (spy-on 'mock-eglot--workspace-configuration-plist + :and-return-value + '(:python + (:pythonPath + "/usr/bin/python" + :venvPath + "/home/user/project/"))) + (spy-on 'process-command :and-return-value '("/usr/bin/pyright-langserver")) + (spy-on 'pet-lookup-eglot-server-initialization-options) + + (expect (pet-eglot--workspace-configuration-plist-advice + 'mock-eglot--workspace-configuration-plist + "server") + :to-equal '(:python + (:pythonPath + "/usr/bin/python" + :venvPath + "/home/user/project/"))) + + (expect 'mock-eglot--workspace-configuration-plist :to-have-been-called)) + + (it "should return dir local variables and pet server initialization options when both available" + (spy-on 'mock-eglot--workspace-configuration-plist + :and-return-value + '(:python + (:pythonPath + "/usr/bin/python"))) + (spy-on 'process-command :and-return-value '("/usr/bin/pyright-langserver")) + (spy-on 'pet-lookup-eglot-server-initialization-options + :and-return-value + '(:python + (:venvPath + "/home/user/project/"))) + + (expect (pet-eglot--workspace-configuration-plist-advice + 'mock-eglot--workspace-configuration-plist + "server") + :to-equal '(:python + (:pythonPath + "/usr/bin/python" + :venvPath + "/home/user/project/"))) + + (expect 'mock-eglot--workspace-configuration-plist :to-have-been-called))) + +(describe "pet-eglot--guess-contact-advice" + (it "should return output unchanged when there's no pet server initialization" + (spy-on 'eglot--guess-contact :and-return-value + '((python-mode python-ts-mode) + "project" + 'eglot-lsp-server + ("pyright-langserver" "--stdio") + "python-ts")) + + (spy-on 'pet-lookup-eglot-server-initialization-options) + + (expect + (pet-eglot--guess-contact-advice 'eglot--guess-contact) + :to-equal + '((python-mode python-ts-mode) + "project" + 'eglot-lsp-server + ("pyright-langserver" "--stdio") + "python-ts"))) + + (it "should return contact with default server initialization when there's no pet server initialization" + (spy-on 'eglot--guess-contact :and-return-value + '((python-mode python-ts-mode) + "project" + 'eglot-lsp-server + ("jedi-language-server" + :initializationOptions + (:jedi + (:executable + (:args ["--ws"])))) + "python-ts")) + + (spy-on 'pet-lookup-eglot-server-initialization-options :and-return-value + '(:jedi + (:executable + (:command + "/home/user/project/env/bin/jedi-language-server") + :workspace + (:environmentPath + "/home/user/project/env/bin/python")))) + + (expect + (pet-eglot--guess-contact-advice 'eglot--guess-contact) + :to-equal + '((python-mode python-ts-mode) + "project" + 'eglot-lsp-server + ("jedi-language-server" + :initializationOptions + (:jedi + (:executable + (:args + ["--ws"] + :command + "/home/user/project/env/bin/jedi-language-server") + :workspace + (:environmentPath + "/home/user/project/env/bin/python")))) + "python-ts")))) + +(describe "pet-lookup-eglot-server-initialization-options" + (before-each + (spy-on 'pet-virtualenv-root :and-return-value "/home/user/project/") + (spy-on 'pet-executable-find :and-call-fake + (lambda (command) + (assoc-default command + '(("flake8" . "/usr/bin/flake8") + ("pylint" . "/usr/bin/pylint") + ("python" . "/usr/bin/python") + ("jedi-language-server" . "/home/user/.local/bin/jedi-language-server")))))) + + (it "should return eglot initialization options for pylsp" + (expect (pet-lookup-eglot-server-initialization-options "/home/user/.local/bin/pylsp") :to-equal + '(:pylsp + (:plugins + (:jedi + (:environment + "/home/user/project/") + :flake8 + (:executable + "/usr/bin/flake8") + :pylint + (:executable + "/usr/bin/pylint")))))) + + (it "should return eglot initialization options for pyls" + (expect (pet-lookup-eglot-server-initialization-options "/home/user/.local/bin/pyls") :to-equal + '(:pyls + (:plugins + (:jedi + (:environment + "/home/user/project/") + :pylint + (:executable + "/usr/bin/pylint")))))) + + (it "should return eglot initialization options for pyright" + (expect (pet-lookup-eglot-server-initialization-options "/home/user/.local/bin/pyright-langserver") :to-equal + `(:python + (:pythonPath + "/usr/bin/python" + :venvPath + "/home/user/project/")))) + + (it "should return eglot initialization options for jedi-language-server" + (expect (pet-lookup-eglot-server-initialization-options "jedi-language-server") :to-equal + '(:jedi + (:executable + (:command + "/home/user/.local/bin/jedi-language-server") + :workspace + (:environmentPath + "/usr/bin/python")))))) + +(describe "pet-merge-eglot-initialization-options" + (it "should deeply merge 2 plists" + (expect + (pet-merge-eglot-initialization-options + '(:a (:b [1 2] :c 0 :d "hello" :f :json-null)) + '(:a (:b [3 4] :c 9 :e "world" :g :json-false))) + :to-equal + '(:a (:b [1 2 3 4] :c 9 :d "hello" :f :json-null :e "world" :g :json-false))))) + +(describe "pet-eglot-setup" + (before-each + (pet-eglot-setup)) + + (after-each + (pet-eglot-teardown)) + + (it "should advice eglot functions" + (pet-eglot-setup) + (expect (advice-member-p 'pet-eglot--workspace-configuration-plist-advice 'eglot--workspace-configuration-plist) :to-be-truthy) + (expect (advice-member-p 'pet-eglot--executable-find-advice 'eglot--executable-find) :to-be-truthy) + (expect (advice-member-p 'pet-eglot--guess-contact-advice 'eglot--guess-contact) :to-be-truthy))) + +(describe "pet-eglot-teardown" + (it "should remove `pet' advices from eglot functions" + (pet-eglot-setup) + (pet-eglot-teardown) + (expect (advice-member-p 'pet-eglot--workspace-configuration-plist-advice 'eglot--workspace-configuration-plist) :to-be nil) + (expect (advice-member-p 'pet-eglot--executable-find-advice 'eglot--executable-find) :to-be nil) + (expect (advice-member-p 'pet-eglot--guess-contact-advice 'eglot--guess-contact) :to-be nil))) + (describe "pet-buffer-local-vars-setup" (after-each (kill-local-variable 'python-shell-interpreter)