Skip Navigation

Advise Eglot to Support Clojure Monorepo Setup

Published: 2022-02-27 (last updated: 2022-03-28)

#clojure#emacs

Updates 2022-03-28: A Better Solution

Since I published the original solution, I've found certain things were not working as I claimed. There's a better solution, which is mentioned shortly in this section, without hacking Emacs lisp code. Because my intent to write this article is less about giving you the solution but more about demonstrating a way to tweak Emacs' package to fit your own desires, I decided to keep its original content alive, with the hope that it would still be insightful to some of you.

Here's the updated solution! Since clojure-lsp issue #752, clojure-lsp uses the classpath to discover the source code to analyze. The trick is to merge all the classpaths from the monorepo's sub-projects. Conveniently, lein-monolith provides a way to Merge Source Profile. Therefore we can invoke the command lein monolith with-all classpath instead. Your clojure-lsp configuration file that goes to <monorepo-root>/.lsp/config.edn should look like this:

{:project-specs [{:project-path "project.clj"
                  :classpath-cmd ["lein" "monolith" "with-all" "classpath"]}]}

Note that if you have a large monorepo, you might need to bump the eglot-connect-timeout to a larger number to let clojure-lsp finish analyzing all the source code. I set my eglot-connect-timeout to 600 currently.


The following is the original content.

Abstract

The advice system and dynamic scoping are two powerful facilities in Emacs. In this article, I'll show you how to use them to tweak Emacs' behaviors and facts to workaround the eglot package while setting up clojure-lsp for a monorepo.

Background

In Emacs, there are two popular LSP clients: lsp-mode and eglot. Although historically, there were more noticeable differences between the two packages in terms of their usage and their UI, these days, I'm pretty happy with both of them. I can achieve pretty minimal UI in lsp-mode (as eglot always is 🙂) by tweaking several custom settings, and both work well with built-in packages, which I've grown accustomed to over the years.

Over the past few years of using Emacs, I started to prefer the class of packages that work very closely with the Emacs' built-in facilities instead of inventing their isolated world1. Such packages provide a narrow focus that enhances a specific part of Emacs. I love this class of packages because I can simply reject a dependency if it's not essential to my workflow. Therefore, I decided to hop back from lsp-mode to eglot for its simplicity.

Because of clojure-lsp's limitation on discovering source code (issue #191 and issue #551) with the lein-monolith setup and my needs at work, I didn't change the :project-paths setting as others did (e.g., this comment and this gist). Instead, I run a different clojure-lsp server session for each sub-project. In lsp-mode, this can be done by customizing the project root the first time launching the clojure-lsp. Next, I'll show you the issue I faced using eglot and how to teach it (or advise it 😉) to work around it.

Problem

When eglot starts the clojure-lsp server, it infers2 the project root by utilizing the project.el built-in package. Out of the box, Emacs consider the project root to be the current version-controlled root directory. If you are using git, the project root is the parent folder of the .git/ folder. While this inference is correct most of the time, I want this to be different for my monorepo project at work, which has the directory structure like this:

root/
  |--- .git/
  |--- projects.clj
  |--- project-a/
  |      |--- projects.clj
  |      |--- src/
  |--- project-b/
  |      |--- projects.clj
  |      |--- src/
  .
  .
  .
  |--- project-n
         |--- projects.clj
         |--- src/

The root/ is the root of our monorepo, which uses the projects.clj file and lein-monolith to tie all of its sub-projects together. All sub-projects are version-controlled together under root/. The root/ project itself does not contain any source code, whereas the sub-projects do.

With this setup, editing any source file in any sub-project will connect to the clojure-lsp server session at root by default. The question is: how to tweak eglot to use the sub-project root as the clojure-lsp server session?

Solution

I came up with this solution that ensures the eglot-ensure function will try to locate the clojure-project-dir as the project root when launching an LSP session. If clojure-project-dir isn't found, it falls back to using the version-control root to launch the LSP server.

;; My personal settings that you might not require
;;(add-hook 'clojure-mode-hook 'eglot-ensure)
;;(custom-set-variables '(eglot-connect-timeout 300))

(defun project-try-clojure-project (dir)
  "Try to locate a Clojure project."
  (when-let ((found (clojure-project-dir)))
    (cons 'transient found)))

(defun find-clojure-project-advice (orig-fun &rest args)
  "Fix project-root for the clojure monorepo setup."
  (let ((project-find-functions
         (cons 'project-try-clojure-project project-find-functions)))
    (apply orig-fun args)))

(advice-add 'eglot-ensure :around #'find-clojure-project-advice)

But how does this work?

Teaching Emacs new tricks by giving it dynamic advices

Aspect-Oriented Programming page on Wikipedia:

In computing, aspect-oriented programming (AOP) is a programming paradigm that aims to increase modularity by allowing the separation of cross-cutting concerns. It does so by adding additional behavior to existing code (an advice) without modifying the code itself, instead separately specifying which code is modified via a "pointcut" specification, such as "log all function calls when the function's name begins with 'set'".

I only learned the concept of AOP because of Emacs. It fits surprisingly well for a plug-in system in programs like Emacs. The users of Emacs are empowered to enrich their experience with third-party packages and are empowered to hack the behaviors (functions) of any package from afar through the advice system without touching the source code of those packages.

Not only are the behaviors hackable, but the facts (variables) are also modifiable thanks to Dynamic Scoping. Dynamic Scoping gives the Emacs users the power to change variable bindings with a dynamic extend.

(defun project-try-clojure-project (dir)
  "Try to locate a Clojure project."
  (when-let ((found (clojure-project-dir)))
    (cons 'transient found)))

Here we define a backend for the project-find-functions. If a clojure-project-dir is found, project-try-clojure-project returns the tuple (transient . <PROJECT DIR PATH STRING>), otherwise nil. This function's signature will satisfy as an element in the project-find-functions. However, we don't want to change the behavior across the board. We want to limit the scope of the modified project-find-functions variable. So we use a let-binding to change the dynamic scope:

(defun find-clojure-project-advice (orig-fun &rest args)
  "Fix project-root for the clojure monorepo setup."
  (let ((project-find-functions
         (cons 'project-try-clojure-project project-find-functions)))
    (apply orig-fun args)))

The find-clojure-project-advice function binds the project-find-functions variable with the project-try-clojure-project function as the first element in the list. This binding only exists to the extent of this function call. Therefore, invoking project-current function under other contexts will not be affected.

(advice-add 'eglot-ensure :around #'find-clojure-project-advice)

Lastly, we use the add-advice to advise the eglot-ensure function to invoke the find-clojure-project-advice. The :around keyword denotes that we want to compose the advising function (find-clojure-project-advice) around the advised function (eglot-ensure) so the let-binding can enter the dynamic scope of eglot-ensure.

Conclusion

To recap, this article:

Without modifying any source code of Emacs or its packages, we can change the behavior (the eglot-ensure function) and the fact (the project-find-functions variable) to satisfy a specific use case while keeping other parts of Emacs intact. I think this is a great example to showcase why Emacs is so powerful and valuable to its users.

Footnotes


  1. This philosophy plays well with my favorite programming language, Clojure. The community has learned to work together by creating single-purpose libraries that strongly prefer to use Clojure data as the universal language.

  2. eglot--guess-contact is the internal function that makes this inference.

This work is licensed under a Creative Commons Attribution 4.0 International License.