Andrey Listopadov

Project.el enhancements

After publishing the last post I thought why won’t I post such things here occasionally? I have a few pieces of Emacs Lisp in my configuration that I wrote for myself some time ago to fix some annoyances or improve a certain workflow. Previously I’ve used a literate approach for my Emacs configuration which listed all these notes, but it was tedious to maintain, so I’ve opted out for a simpler init.el. But I still would like to share some pieces in a more elaborated form. Moreover, I’ve already done it before, and now, since I don’t use these packages, it’s easier to find these in a more organized place, than search in the commit history.

One such thing that I’d like to share is a configuration of the inbuilt project.el, which makes it a bit more pleasant to work with.

Finding project root based on specific root-markers

Long ago I added a function that searches for a project root a bit differently from how project.el does it by default. It does so by searching for specific files, that I’ve specified like this:

(defcustom project-root-markers
  '("Cargo.toml" "compile_commands.json" "compile_flags.txt"
    "project.clj" ".git" "deps.edn" "shadow-cljs.edn")
  "Files or directories that indicate the root of a project."
  :type '(repeat string)
  :group 'project)

You may wonder why I’ve included .git as a project marker, but I often work with submodules, and I want such submodules to be treated as separate projects. The default project-try-vc treats submodules as part of a bigger project, which is logical, I guess, but I want it to act a bit differently. And since not all of my submodules have one of the specified markers, it’s easy to search for the git directory and be done with it.

Next, we need a function that checks if a given path has any of these markers:

(defun project-root-p (path)
  "Check if the current PATH has any of the project root markers."
  (catch 'found
    (dolist (marker project-root-markers)
      (when (file-exists-p (concat path marker))
        (throw 'found marker)))))

Nothing fancy, just a linear search for any of the markers under the current directory. The search terminates as soon as one of the files is found.

The main piece here is the project-find-root function:

(defun project-find-root (path)
  "Search up the PATH for `project-root-markers'."
  (let ((path (expand-file-name path)))
    (catch 'found
      (while (not (equal "/" path))
        (if (not (project-root-p path))
            (setq path (file-name-directory (directory-file-name path)))
          (throw 'found (cons 'transient path)))))))
(add-to-list 'project-find-functions #'project-find-root)

As can be seen, this function is added to the list of project-find-functions, and all it does is simply look for a marker file in the current directory, and if it is not found it goes up the directory. If file is found, it returns a (transient . "path/to/root") that project.el expects. In practice, with this function early enough in the list, project-try-vc is never called, and I get very predictable project roots.

Update:

A suggestion came from another Emacs user, that there’s a function that can search up from the current directory, called locate-dominating-file. It accepts a path as its first argument, and a file to search for as a second argument. It may not seem useful in this use case, since we need to search for multiple files, but instead of a file, this function also can accept a predicate, that will tell if a given file exists. Thus, the project-find-root function can be rewritten as:

(defun project-find-root (path)
  "Search up the PATH for `project-root-markers'."
  (when-let ((root (locate-dominating-file path #'project-root-p)))
    (cons 'transient (expand-file-name root))))

This is why I love Emacs! It has a lot of functions ready to be used, and a passionate community, that suggests improvements and makes the experience even better.

Saving only project buffers before compilation

I often use project’s commands, defined under the project-prefix-map. One of these is project-compile, which I use for all sorts of things, from running tests to deploying. However, it has an annoying habit to ask whether I want to save some buffers before I run any command. Why yes, I want to save some buffers, but only if they belong to the current project! So let’s fix this:

(defun project-save-some-buffers (&optional arg)
  "Save some modified file-visiting buffers in the current project.

Optional argument ARG (interactively, prefix argument) non-nil
means save all with no questions."
  (interactive "P")
  (let* ((project-buffers (project-buffers (project-current)))
         (pred (lambda () (memq (current-buffer) project-buffers))))
    (funcall-interactively #'save-some-buffers arg pred)))

Just a simple function that obtains the current project, queries all project buffers, and creates a custom predicate for the save-some-buffers function. I’m not sure why project.el doesn’t do it by itself, but we can always advise things:

(define-advice project-compile (:around (fn) save-project-buffers)
  "Only ask to save project-related buffers."
  (let* ((project-buffers (project-buffers (project-current)))
         (compilation-save-buffers-predicate
          (lambda () (memq (current-buffer) project-buffers))))
    (funcall fn)))

This fixes only half of the problem, however.

Imagine, you’re running tests by calling project-compile and one of the tests fails. You look in the *compilation* buffer why it failed and fix it. Then you go to the *compilation* buffer once again and hit the g key to re-run tests. But instead of running tests, you’re being asked if you want to save some buffers, even though you’ve saved all buffers in the project beforehand. Turns out, that g simply calls the recompile function, which knows nothing about the project, and will prompt you to save buffers, that aren’t in the project and are unsaved. This, once again, can be fixed with a piece of advice:

(define-advice recompile (:around (fn &optional edit-command) save-project-buffers)
  "Only ask to save project-related buffers if inside a project."
  (if (project-current)
      (let* ((project-buffers (project-buffers (project-current)))
             (compilation-save-buffers-predicate
              (lambda () (memq (current-buffer) project-buffers))))
        (funcall fn edit-command))
    (funcall fn edit-command)))

We simply check if current *compilation* buffer is a part of a project, since and do the same trick, setting compilation-save-buffers-predicate to our custom predicate that is aware of the project. This, however, works best when you customize the project-compilation-buffer-name-function to use project-prefixed-buffer-name, so you can compile multiple projects, and it won’t be confusing when to save what buffers.