with apologies

E(pi)glot-tal start

· 5 min read · October 29, 2025 · #tech #linux #emacs #nixos

Following on from an earlier post about Tree-sitter, Eglot is the Emacs Language Server Protocol (LSP) client. Being also now included in Emacs from v29, it works well with Tree-sitter, integrating servers that can provide rich semantic analysis and tooling for a wide range of languages.

Basic Eglot configuration

I split my Emacs configuration for Eglot across some default configuration plus language specific modes, as far as possible. Default configuration first:

(use-package eglot
  :config
  (setq-default eglot-workspace-configuration ...) ;; ...but see below

  :custom
  (eglot-send-changes-idle-time 0.5)
  (eglot-extend-to-xref t)

  :hook
  (eglot-managed-mode . eglot-inlay-hints-mode)
  (prog-mode
   .
   (lambda ()
     (unless (eq major-mode 'emacs-lisp-mode)
       (eglot-ensure))))
  (before-save
   .
   (lambda ()
     (if (eglot-managed-p)
         (eglot-format))))

  :bind
  (:map
   eglot-mode-map
   ("C-c c a" . eglot-code-actions)
   ("C-c c o" . eglot-code-actions-organize-imports)
   ("C-c c r" . eglot-rename)
   ("C-c c f" . eglot-format)))

This sets up Eglot to run for all programming modes (prog-mode) except emacs-lisp-mode because there is no LSP for that, it being rather integral to Emacs itself! I will also configure it for a few non-programming modes in other relevant use-package stanzas. Finally, I configure calling Eglot’s (language specific) format function before saving, and a few useful key-bindings. See below for workspace configuration.

I also add eglot-booster as that enables Eglot to use the emacs-lsp-booster wrapper which should speed up LSP server interactions:

(use-package eglot-booster
  :vc (:url "https://github.com/jdtsmith/eglot-booster")
  :after eglot
  :config (eglot-booster-mode))

Configuring for specific languages

Simple: TOML and Bash

Configuring for a particular language is then relatively straightforward for some. For example, for TOML I use Tombi in the tree-sitter TOML mode, toml-ts-mode:

(use-package toml-ts-mode
  :mode "\\.toml\\'"
  :hook eglot-ensure ;; as this is not a `prog-mode` so not covered by the default above
  :config
  (add-to-list 'major-mode-remap-alist '((toml-mode . toml-ts-mode)))
  (add-to-list 'eglot-server-programs '(toml-ts-mode . ("tombi" "lsp"))))

This replaces the default toml-mode with the tree-sitter toml-ts-mode, and ensures the Tombi TOML language server is running when in that mode.

Bash is similarly straightforward using the bash-language-server:

(use-package bash-ts-mode
  :ensure nil ;; as there's no package to install, and I set `:ensure t` by default
  :custom (tab-width 2)
  :config
  (add-to-list
   'major-mode-remap-alist '((bash-mode . bash-ts-mode) (sh-mode . bash-ts-mode))))

This stops use-package complaining that there is no bash-ts-mode package, sets tabs to be just two spaces (YMMV), and remaps the default bash-/sh- modes to use this bash-ts-mode. I don’t need to set eglot-server-programs in this case because the pre-configured default is set to use bash-language-server appropriately.

Slightly more complex: Nix

Nix is a little more complex as I would like to use the (non-preconfigured default) nixd LSP server and configure it to use nixfmt for formatting. In this case I do so by passing in :initializationOptions:

(use-package nix-ts-mode
  :mode "\\.nix\\'"
  :config
  (add-to-list 'major-mode-remap-alist '((nix-mode . nix-ts-mode)))
  (add-to-list
   'eglot-server-programs
   '((nix-mode nix-ts-mode)
     .
     ("nixd"
      "--semantic-tokens"
      "--inlay-hints"
      :initializationOptions (:nixd (:formatting.command "nixfmt"))))))

As with bash-ts-mode, this remaps the default nix-mode to nix-ts-mode, but then goes on to specify the language server command line to run, and an initialisation option to use nixfmt for formatting.

More complex: Python, LaTeX, Typst

Python is more complex as I use ruff for formatting but it doesn’t support much beyond that at present so I use basedpyright for type hinting and the rest. After installing the NixOS basedpyright, ruff, and python313Packages.python-lsp-server packages, this results in:

(use-package python-ts-mode
  :ensure nil
  :after (eglot reformatter)
  :preface
  ;; per https://ddavis.io/blog/python-emacs-4/
  (reformatter-define
   dd/ruff-format
   :program "ruff" ;; "uvx" with "ruff" as an arg doesn't work because dynamic linking on NixOS
   :args `("format" "--stdin-filename" ,buffer-file-name "-"))
  (reformatter-define
   dd/ruff-sort
   :program "ruff"
   :args
   `("check" "--select" "I" "--fix" "--stdin-filename" ,buffer-file-name "-"))

  :init
  (add-to-list
   'eglot-server-programs
   `((python-mode python-ts-mode) . ("basedpyright-langserver" "--stdio")))

  :hook
  ((python-base-mode . dd/ruff-format-on-save-mode)
   (python-base-mode . dd/ruff-sort-on-save-mode)))

This essentially does three things: defines two reformatter functions using ruff, one to reformat the code and the other to sort imports; and sets basedpyright as the language server to use, by shadowing the default entries for Python in eglot-server-programs.

I also turn on uv-mode as I use uv for all Python package management:

(use-package uv-mode
  :after (eglot python-base-mode)
  :hook (python-base-mode . uv-mode-auto-activate-hook))

Finally, for the purposes of this blog post, for document processing I use LaTeX historically and am now trying to use typst. LaTeX first using the eglot-ltex-plus server:

(use-package eglot-ltex-plus
  :vc (:url "https://github.com/emacs-languagetool/eglot-ltex-plus" :rev :newest)
  :custom
  (eglot-ltex-plus-server-path
   "/nix/store/3ihx8s39rl2d5by5wabcg3i4rcm3kns3-ltex-ls-plus-18.6.0/")
  (eglot-ltex-plus-communication-channel 'stdio))

(use-package latex
  :ensure auctex
  :mode
  (("\\.tex\\'" . latex-mode)
   ("\\.latex\\'" . latex-mode)
   ("\\.bibtex\\'" . bibtex-mode))
  :hook
  ((LaTeX-mode . LaTeX-math-mode)
   (LaTeX-mode . turn-on-reftex)
   (LaTeX-mode . TeX-fold-mode))

   ...
)

See my init.el for latex package configuration details.

Finally finally, typst:

(use-package typst-ts-mode
  :mode "\\.typ\\'"
  :hook (typst-ts-mode . eglot-ensure)

  :custom
  (typst-ts-lsp-download-path (string-trim (shell-command-to-string "which tinymist")))
  (typst-ts-mode-enable-raw-blocks-highlight t)

  :config
  (add-to-list 'major-mode-remap-alist '(typst-mode . typst-ts-mode))
  (add-to-list 'tree-sitter-major-mode-language-alist '(typst-ts-mode . typst))
  (add-to-list
   'eglot-server-programs
   `((typst-ts-mode)
     .
     ,(eglot-alternatives `(,typst-ts-lsp-download-path "tinymist" "typst-lsp"))))

  (defun typst-ts-tinymist-preview ()
    "Run `tinymist preview` on the current file."
    (interactive)
    (let ((file (buffer-file-name)))
      (if file
          (compile (format "tinymist preview %s" (shell-quote-argument file)))
        (user-error "Buffer is not visiting a file"))))
  :bind ("C-c C-x" . #'typst-ts-tinymist-preview)
  :bind (:map typst-ts-mode-map ("C-c C-c" . typst-ts-tmenu)))

Both of the above have to set various other options as well as configuring Eglot.

But wait! There’s more! Specifically, for Bash, LaTeX, and Python, I need to provide a default workspace configuration to set some sensible options.

Workspace configuration

This took me a little while to figure out. LSP servers assume a per-project “workspace”, apparently normally set from the project’s configuration file (either editor-independent but language-specific, or the Emacs specific but language-independent .dir-locals.el). As a now only occasional coder I prefer to set a global default and mostly rely on that.

However, the extra little fillip here is that you can’t set it locally in a buffer – and that includes via a major-mode hook (discussed here). I investigated using the :initializationOptions argument when setting eglot-server-programs so that I could keep all language-specific configuration together, but that appears to be supported by only some language servers :( So, for example, the nixd example uses it to configure use of nixfmt for formatting and it seems to work fine, but the equivalent for both Bash and Python did not.

So, I ended up just setting it in the Eglot configuration stanza using setq-default:

(use-package eglot
  :config
  (setq-default
   eglot-workspace-configuration
   '(
     ;; sh/bash
     :bashIde
     (:backgroundAnalysisMaxFiles
      0 ;; turn off background analysis of directory tree
      :shfmt (:binaryNextLine t :caseIndent t :simplifyCode t :spaceRedirects t))

     ;; latex
     :ltex-ls-plus
     (:language
      "en-GB"
      :additionalRules (:enablePickyRules t :motherTongue "en-GB")
      :completionEnabled t)

     ;; python
     :basedpyright (:disableOrganizeImports t)
     :basedpyright.analysis
     (:typeCheckingMode
      "all"
      :autoImportCompletions t
      :inlayHints (:callArgumentNamesMatching t)
      :useTypingExtensions t)

     ;;
     ))

  ...

The final little nugget that I had to figure out to make the above work was what is the magic string that identifies the server so that it will process the correct configuration blob when it’s sent. Turns out this really is just a magic string and I needed to peruse docs and ultimately source to find it. So for example, for bash-language-server it turns out to be bashIde.

Possibly good general places to start looking are in the *EGLOT* logs from the server, your *MESSAGES* buffer, the server test for processing onDidChangeConfigration or (if one exists, and it probably does given the prevalence of LSPs in VSCode-land) in the vscode-client packaging, or some suitable declaration – in the case of bash-language-server, this turns out to be setting the variable CONFIGURATION_SECTION.

Anyway. That mostly concludes my Emacs polishing. For now. Probably.