eglot-python-preset.el

;;; eglot-python-preset.el --- Eglot preset for Python -*- lexical-binding: t; -*-

;; Copyright (C) 2025-2026 Michael Olson <mwolson@gnu.org>

;; Version: 0.3.0
;; Author: Michael Olson <mwolson@gnu.org>
;; Maintainer: Michael Olson <mwolson@gnu.org>
;; URL: https://github.com/mwolson/eglot-python-preset
;; SPDX-License-Identifier: GPL-3.0-or-later
;; Keywords: python, convenience, languages, tools
;; Package-Requires: ((emacs "30.2") (eglot "1.17"))

;; This file is NOT part of GNU Emacs.

;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.

;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU General Public License for more details.

;; You should have received a copy of the GNU General Public License
;; along with this program.  If not, see <https://www.gnu.org/licenses/>.

;;; Commentary:

;; This package provides a preset for Eglot to work with Python files,
;; including support for PEP-723 script metadata and project detection.
;; It configures the LSP server (ty or basedpyright) and handles environment
;; synchronization for uv-managed scripts.

;; Prerequisites:
;;
;; - Install uv
;; - Install ty (>= v0.0.8) or basedpyright as a Python language server
;; - Download this file and add it to the load path

;; Quick start:
;;
;;   (require 'eglot-python-preset)
;;   (setopt eglot-python-preset-lsp-server 'ty)
;;   ;; or
;;   ;; (setopt eglot-python-preset-lsp-server 'basedpyright)
;;   (eglot-python-preset-setup)
;;
;; After that, opening Python files will automatically start the LSP server using
;; Eglot and handle PEP-723 magic tags within files.

;; Workspace configuration (basedpyright):
;;
;; To customize basedpyright settings, set `eglot-workspace-configuration'
;; before calling `eglot-python-preset-setup'.  Your settings will be merged
;; with PEP-723 script configurations (which add :python :pythonPath).
;;
;; Example:
;;
;;   (setopt eglot-workspace-configuration
;;           '(:basedpyright.analysis
;;             (:autoImportCompletions :json-false
;;              :typeCheckingMode "basic")))
;;   (eglot-python-preset-setup)

;;; Code:

(require 'cl-lib)

(declare-function eglot--managed-buffers "eglot")
(declare-function eglot-current-server "eglot")
(declare-function eglot-ensure "eglot")
(declare-function eglot-shutdown "eglot")

(defvar eglot-server-programs)
(defvar eglot-workspace-configuration)

(defgroup eglot-python-preset nil
  "Python preset for Eglot."
  :group 'eglot
  :prefix "eglot-python-preset-")

;;;###autoload
(defcustom eglot-python-preset-lsp-server 'ty
  "LSP server to use for Python files."
  :type '(choice (const :tag "ty" ty)
                 (const :tag "basedpyright" basedpyright))
  :group 'eglot-python-preset)

;;;###autoload
(defcustom eglot-python-preset-python-project-markers
  '("pyproject.toml" "requirements.txt")
  "Files that indicate a Python project root."
  :type '(repeat string)
  :group 'eglot-python-preset)

(defun eglot-python-preset-has-metadata-p ()
  "Return non-nil if current buffer contains PEP-723 script metadata."
  (let ((case-fold-search nil))
    (when (buffer-file-name)
      (save-match-data
        (save-excursion
          (save-restriction
            (widen)
            (goto-char (point-min))
            (when (re-search-forward "^# /// script$" nil t)
              (re-search-forward "^# ///$" nil t))))))))

(defun eglot-python-preset--uv-env-dir ()
  "Return the uv script environments directory.

Uses `uv cache dir` to get the cache location, then appends environments-v2/."
  (let ((cache-dir (string-trim (shell-command-to-string "uv cache dir"))))
    (unless (string-empty-p cache-dir)
      (file-name-as-directory
       (expand-file-name "environments-v2" cache-dir)))))

(defvar eglot-python-preset--warned-scripts (make-hash-table :test 'equal)
  "Hash table tracking scripts we've warned about unsynced environments.")

(defun eglot-python-preset-get-python-path (script-path)
  "Get Python interpreter path for SCRIPT-PATH using uv.

Runs `uv python find --script SCRIPT-PATH'.
Displays a warning if the environment needs to be synced.
Returns the Python path, or nil if uv is not available."
  (if (not (executable-find "uv"))
      (progn
        (message "[eglot-python-preset] uv not found")
        nil)
    (let* ((script-path (expand-file-name script-path))
           (env-dir (eglot-python-preset--uv-env-dir))
           (default-directory (file-name-directory script-path))
           (output (shell-command-to-string
                    (format "uv python find --script %s"
                            (shell-quote-argument script-path))))
           (python-path (string-trim output)))
      (cond
       ((string-empty-p python-path)
        (message "[eglot-python-preset] uv couldn't find Python for %s"
                 script-path)
        nil)
       ((and env-dir (string-prefix-p env-dir python-path))
        python-path)
       (t
        (unless (gethash script-path eglot-python-preset--warned-scripts)
          (puthash script-path t eglot-python-preset--warned-scripts)
          (display-warning
           'eglot-python-preset
           "Environment not synced. Run M-x eglot-python-preset-sync-environment"
           :warning))
        python-path)))))

(defun eglot-python-preset--python-env-dir (python-path)
  "Return the environment directory for PYTHON-PATH.

Given a path like /path/to/env/bin/python3, return /path/to/env/."
  (when python-path
    (let ((bin-dir (file-name-directory python-path)))
      (when (string-match-p "/bin/?$" bin-dir)
        (file-name-directory (directory-file-name bin-dir))))))

(defun eglot-python-preset--project-root ()
  "Return the current buffer's project root."
  (when-let* ((file (buffer-file-name))
              (project (eglot-python-preset--project-find
                        (file-name-directory file))))
    (project-root project)))

(defun eglot-python-preset--venv-bin-dirs ()
  "Return candidate local virtualenv executable directories."
  (when-let* ((root (eglot-python-preset--project-root))
              (venv-dir (expand-file-name ".venv/" root)))
    (delq nil
          (mapcar (lambda (path)
                    (when (file-directory-p path)
                      path))
                  (list (expand-file-name "bin" venv-dir)
                        (expand-file-name "Scripts" venv-dir))))))

(defun eglot-python-preset--resolve-executable (name)
  "Resolve executable NAME, preferring the current project's local `.venv'."
  (or (cl-loop for dir in (eglot-python-preset--venv-bin-dirs)
               thereis (let ((exec-path (cons dir exec-path)))
                         (executable-find name)))
      (executable-find name)
      name))

(defun eglot-python-preset--merge-plists (base override)
  "Recursively merge OVERRIDE plist into BASE plist.

For keys present in both, OVERRIDE values take precedence.
If both values are plists, merge them recursively."
  (let ((result (copy-sequence base)))
    (cl-loop for (key val) on override by #'cddr
             do (let ((base-val (plist-get result key)))
                  (setq result
                        (plist-put result key
                                   (if (and (listp base-val)
                                            (listp val)
                                            (keywordp (car-safe base-val))
                                            (keywordp (car-safe val)))
                                       (eglot-python-preset--merge-plists
                                        base-val val)
                                     val)))))
    result))

(defun eglot-python-preset--workspace-configuration-plist-a (orig-fn server &optional path)
  "Advice to merge PEP-723 Python path into workspace configuration.

Calls ORIG-FN with SERVER and PATH arguments, then merges :python :pythonPath
for PEP-723 scripts when using basedpyright.

Gets the first managed buffer from SERVER to check for PEP-723 metadata,
since PATH is typically a directory (project root) or nil."
  (let ((base-config (funcall orig-fn server path)))
    (if-let* (((eq eglot-python-preset-lsp-server 'basedpyright))
              (buf (car (eglot--managed-buffers server)))
              (file-path (buffer-file-name buf))
              ((with-current-buffer buf (eglot-python-preset-has-metadata-p)))
              (python-path (eglot-python-preset-get-python-path file-path)))
        (eglot-python-preset--merge-plists
         base-config
         `(:python (:pythonPath ,python-path)))
      base-config)))

(defun eglot-python-preset--init-options ()
  "Return initializationOptions for ty LSP server.

For PEP-723 scripts, includes environment configuration.
Only used for ty; basedpyright uses workspace configuration instead."
  (when (eq eglot-python-preset-lsp-server 'ty)
    (when-let* ((file (buffer-file-name))
                ((eglot-python-preset-has-metadata-p))
                (script-dir (file-name-directory file))
                (python-path (eglot-python-preset-get-python-path file)))
      (let ((env-dir (or (eglot-python-preset--python-env-dir python-path)
                         python-path)))
        `(:configuration
          (:environment
           (:python ,env-dir
            :root [,script-dir])))))))

(defun eglot-python-preset--server-contact (_interactive)
  "Return the server contact spec for Python LSP.

Includes initializationOptions for ty with PEP-723 scripts."
  (let ((command (pcase eglot-python-preset-lsp-server
                   ('ty (list (eglot-python-preset--resolve-executable "ty")
                              "server"))
                   ('basedpyright
                    (list (eglot-python-preset--resolve-executable
                           "basedpyright-langserver")
                          "--stdio"))))
        (init-options (eglot-python-preset--init-options)))
    (if init-options
        `(,@command :initializationOptions ,init-options)
      command)))

(defun eglot-python-preset--in-indirect-md-buffer-p ()
  "Return non-nil if buffer is an indirect buffer from a markdown buffer."
  (when-let* ((buf (buffer-base-buffer))
              ((buffer-live-p buf)))
    (with-current-buffer buf
      (derived-mode-p 'markdown-mode))))

(defun eglot-python-preset--python-project-root-p (dir)
  "Return non-nil if DIR contains a Python project marker file."
  (seq-some (lambda (file)
              (file-exists-p (expand-file-name file dir)))
            eglot-python-preset-python-project-markers))

(defun eglot-python-preset--project-find (dir)
  "Project detection for Python files.

For PEP-723 scripts, returns (python-script . SCRIPT-PATH) so each script
gets its own eglot server instance.
Otherwise, returns (python-project . ROOT) if DIR is inside a Python project."
  (cond
   ((eglot-python-preset--in-indirect-md-buffer-p)
    nil)
   ((and (derived-mode-p 'python-base-mode)
         (eglot-python-preset-has-metadata-p))
    (cons 'python-script (buffer-file-name)))
   ((when-let* ((root (locate-dominating-file
                       dir #'eglot-python-preset--python-project-root-p)))
      (cons 'python-project root)))))

(cl-defmethod project-root ((project (head python-script)))
  "Return directory containing the script for PROJECT."
  (file-name-directory (cdr project)))

(cl-defmethod project-root ((project (head python-project)))
  "Return root directory of PROJECT."
  (cdr project))

;;;###autoload
(defun eglot-python-preset-sync-environment ()
  "Sync the current PEP-723 script's environment, then restart Eglot.

Runs `uv sync --script' on the current file.
Uses shutdown + `eglot-ensure' instead of reconnect so that
initializationOptions are recomputed (needed for ty)."
  (interactive)
  (let ((script-path (buffer-file-name)))
    (unless script-path
      (user-error "No file associated with buffer"))
    (unless (executable-find "uv")
      (user-error "Installation for uv not found"))
    (unless (eglot-python-preset-has-metadata-p)
      (user-error "Buffer does not contain PEP-723 metadata"))
    (let* ((default-directory (file-name-directory script-path))
           (status (call-process "uv" nil nil nil "sync" "--script"
                                 script-path)))
      (if (zerop status)
          (progn
            (remhash script-path eglot-python-preset--warned-scripts)
            (when-let* ((win (get-buffer-window "*Warnings*")))
              (delete-window win))
            (message "Environment synced successfully, restarting eglot")
            (when-let* ((server (eglot-current-server)))
              (eglot-shutdown server))
            (eglot-ensure))
        (message "Failed to sync environment (exit code: %s)" status)))))

;;;###autoload
(defun eglot-python-preset-run-script ()
  "Run the current file as a PEP-723 script using `uv run'."
  (interactive)
  (let ((script-path (buffer-file-name)))
    (unless script-path
      (user-error "No file associated with buffer"))
    (unless (eglot-python-preset-has-metadata-p)
      (user-error "Buffer does not contain PEP-723 metadata"))
    (let ((default-directory (file-name-directory script-path)))
      (compile (format "uv run %s" (shell-quote-argument script-path))))))

;;;###autoload
(defun eglot-python-preset-remove-environment ()
  "Remove the cached uv environment for the current PEP-723 script.

This deletes the environment directory created by uv for this script.
After removal, run `eglot-python-preset-sync-environment' to recreate it."
  (interactive)
  (let ((script-path (buffer-file-name)))
    (unless script-path
      (user-error "No file associated with buffer"))
    (unless (executable-find "uv")
      (user-error "Installation for uv not found"))
    (unless (eglot-python-preset-has-metadata-p)
      (user-error "Buffer does not contain PEP-723 metadata"))
    (let ((python-path (eglot-python-preset-get-python-path script-path)))
      (unless python-path
        (user-error "No environment found for this script"))
      (let ((env-dir (eglot-python-preset--python-env-dir python-path))
            (uv-env-dir (eglot-python-preset--uv-env-dir)))
        (unless env-dir
          (user-error "Could not determine environment directory"))
        (unless (file-directory-p env-dir)
          (user-error "Environment directory does not exist: %s" env-dir))
        (unless (and uv-env-dir (string-prefix-p uv-env-dir env-dir))
          (user-error "No uv-managed PEP-723 environment detected:\n\
Run `eglot-python-preset-sync-environment' to create a managed environment"))
        (when (yes-or-no-p (format "Delete environment %s? " env-dir))
          (when-let* ((server (eglot-current-server)))
            (eglot-shutdown server))
          (delete-directory env-dir t)
          (message "Environment removed: %s" env-dir))))))

;;;###autoload
(defun eglot-python-preset-setup ()
  "Set up Eglot to support Python modes, including PEP-723 support.

Adds hooks for project detection and Eglot configuration.
Configures `eglot-server-programs' based on `eglot-python-preset-lsp-server'.
Call this after loading Eglot."
  (interactive)
  (add-to-list 'eglot-server-programs
               '((python-ts-mode python-mode) .
                 eglot-python-preset--server-contact))
  (add-hook 'project-find-functions #'eglot-python-preset--project-find)
  (advice-add 'eglot--workspace-configuration-plist :around
              #'eglot-python-preset--workspace-configuration-plist-a)
  (add-hook 'python-base-mode-hook #'eglot-ensure t))

(provide 'eglot-python-preset)
;;; eglot-python-preset.el ends here