From: Eshel Yaron Date: Wed, 12 Apr 2023 05:44:32 +0000 (+0300) Subject: New blog post about optimizing project selection in Emacs X-Git-Url: http://git.eshelyaron.com/gitweb/?a=commitdiff_plain;h=c47addbb81a7821ecdf8992db25b051c2df36eec;p=esy-publish.git New blog post about optimizing project selection in Emacs --- diff --git a/src/posts/2023-04-11-optimizing-project-selection-in-emacs.org b/src/posts/2023-04-11-optimizing-project-selection-in-emacs.org new file mode 100644 index 0000000..d5cfc75 --- /dev/null +++ b/src/posts/2023-04-11-optimizing-project-selection-in-emacs.org @@ -0,0 +1,207 @@ +#+TITLE: Optimizing Project Selection in Emacs +#+SUBTITLE: Leveraging a new Emacs customization option to streamline project selection +#+DESCRIPTION: A post by Eshel Yaron about leveraging a new Emacs customization option to streamline project selection +#+KEYWORDS: emacs,lisp +#+DATE: 2023-04-11 + +Emacs has a brand new user option for customizing the interface used +for project selection, e.g. when switching from one project to +another. I always considered the way Emacs handles project selection +a bit awkward, so I was glad to see this addition. The new +alternative has some quirks of its own though, so I set out to do a +bit of Elisp hacking in hopes of making this part of my Emacs workflow +behave just right. + +* Project Prompting + +Yesterday [2023-04-10], Spencer Baugh [[https://debbugs.gnu.org/cgi/bugreport.cgi?bug=62759][submitted an Emacs patch]] adding a new user +option to =project.el=, [[info:emacs#Projects][Emacs's bulit-in project isolation package]]. + +The new user option, ~project-prompter~, determines how Emacs prompts for +selecting a project in some =project.el= commands, such as ~C-x p p~. +Previously, these commands would call the function ~project-prompt-project-dir~ +to do that job. Let's have a look at that function's definition: + +#+begin_src emacs-lisp + (defun project-prompt-project-dir () + "Prompt the user for a directory that is one of the known project roots. + The project is chosen among projects known from the project list, + see `project-list-file'. + It's also possible to enter an arbitrary directory not in the list." + (project--ensure-read-project-list) + (let* ((dir-choice "... (choose a dir)") + (choices + ;; XXX: Just using this for the category (for the substring + ;; completion style). + (project--file-completion-table + (append project--list `(,dir-choice)))) + (pr-dir "")) + (while (equal pr-dir "") + ;; If the user simply pressed RET, do this again until they don't. + (setq pr-dir (completing-read "Select project: " choices nil t))) + (if (equal pr-dir dir-choice) + (read-directory-name "Select directory: " default-directory nil t) + pr-dir))) +#+end_src + +/XXX/ marks the hack. The call to ~project--file-completion-table~ creates a +completion table with category ~project-file~, which forces the completion style +~substring~. This is meant to overcome the fact that +~project-prompt-project-dir~ uses ~completing-read~ with full directory paths as +completion candidates--a total pain with Emacs's default completion styles. + +In fact, there are several hacks in this definition that lead to a slightly +awkward user experience. The next hack allows choosing an arbitrary directory +instead of a known project root directory--to do that +~project-prompt-project-dir~ invokes ~completing-read~ with a [[info:elisp#Basic Completion][completion table]] +that consists of the known project root directories along with a dummy candidate +~"... (choose a dir)"~, which always looks a bit out of place in my completions +buffer. + +But the way ~project-prompt-project-dir~ handles empty minibuffer input is even +more baffling--it completely disregards it and prompts you again, in a loop, +until you enter something else or quit with ~C-g~. How's that useful? If the +empty input makes no sense--tell me so! Signal an error, do /something/. +Better yet, provide a /default selection/ on empty input. That's exactly what +~completing-read~'s ~DEFAULT~ argument is there for--just use it. Instead this +function swallows my keystroke with no feedback. I don't like that. + +Still, these are minor inconveniences. My deeper, conceptual, problem with +~project-prompt-project-dir~ is that it prompts me for a /directory/ when really +what I want to choose is a /project/. Of course, in =project.el= there's a +1-to-1 correspondence between projects and their root directories, but this +behavior prevents a useful abstraction. + +* New Possibilities + +With Spencer's patch, the new ~project-prompter~ user option specifies the +function responsible for letting us select a project. By default to +~project-prompt-project-dir~ so to retain the current behavior for unwary users, +while adding a new alternative prompting function called +~project-prompt-project-name~. + +The new alternative let's us select a project /by name/, rather than /by root +directory/. This is a win in my opinion because it enforces a nice abstraction +(projects are distinct from their root directories). It's also more practical +because we can give projects indicative, clearly distinct names even if they +reside in directories with generic and similar names. + +Unfortunately, ~project-prompt-project-name~ inherits most of the problems I +described earlier from ~project-prompt-project-dir~ by virtue of copy-pasta: + +#+begin_src emacs-lisp + (defun project-prompt-project-name () + "Prompt the user for a project, by name, that is one of the known project roots. + The project is chosen among projects known from the project list, + see `project-list-file'. + It's also possible to enter an arbitrary directory not in the list." + (let* ((dir-choice "... (choose a dir)") + (choices + (let (ret) + (dolist (dir (project-known-project-roots)) + ;; we filter out directories that no longer map to a project, + ;; since they don't have a clean project-name. + (if-let (proj (project--find-in-directory dir)) + (push (cons (project-name proj) proj) ret))) + ret)) + ;; XXX: Just using this for the category (for the substring + ;; completion style). + (table (project--file-completion-table (cons dir-choice choices))) + (pr-name "")) + (while (equal pr-name "") + ;; If the user simply pressed RET, do this again until they don't. + (setq pr-name (completing-read "Select project: " table nil t))) + (if (equal pr-name dir-choice) + (read-directory-name "Select directory: " default-directory nil t) + (let ((proj (assoc pr-name choices))) + (if (stringp proj) proj (project-root (cdr proj))))))) +#+end_src + +This could use some polish. Spencer [[https://debbugs.gnu.org/cgi/bugreport.cgi?bug=62759#13][put it nicely]] in response to his patch +landing on Emacs master: + +#+begin_quote +I was expecting to need to iterate through some review cycles :) +#+end_quote + +* Casual Contributor Cap + +Ideally, I'd channel my dissatisfaction with the current implementation to +crafting a follow up patch to Spencer's addition. Alas, [[https://debbugs.gnu.org/cgi/bugreport.cgi?bug=62708#8][I've maxed out my +casual Emacs contributor plan]] and I'm currently waiting for the FSF to process +my copyright assignment papers before I can contribute to Emacs development +again. Even if ~project-prompt-project-name~ isn't quite my cap of tea, I can +still leverage the new ~project-prompter~ user option with a custom +project-prompting function of my own. Enter my new =init.el= +resident -- ~esy/read-project-by-name~: + +#+begin_src emacs-lisp + (defvar esy/project-name-history nil) + + (defvar esy/projects-directory "~/checkouts/") + + (defun esy/read-project-by-name () + "Read a project name and return its root directory. + + If no known project matches the selected name, prompt for a + sub-directory of `esy/projects-directory' using the selected name + as the initial input for completion, and return that directory." + (let* ((name-dir-alist + (mapcar (lambda (dir) + (cons (project-name (project-current nil dir)) + dir)) + (project-known-project-roots))) + (current (project-current)) + (default (and current (project-name current))) + (name (completing-read (format-prompt "Project" default) + name-dir-alist + nil nil nil + 'esy/project-name-history + default))) + (or (alist-get name name-dir-alist nil nil #'string=) + (let* ((dir (read-directory-name "Project root directory: " + esy/projects-directory + nil t name)) + (project (project-current nil dir))) + (when project (project-remember-project project)) + dir)))) +#+end_src + +Similarly to ~project-prompt-project-name~, this function let's me select a +project by name, instead of having to specify its root directory. Where +~esy/read-project-by-name~ differs is in how it handles edge cases, namely: + +1. empty minibuffer input, and +2. unknown project names. + +On empty input (that is, if I press ~RET~ without inserting anything in the +minibuffer first), the current project is selected as the default. I use +~format-prompt~ to have the minibuffer prompt reflect the default choice. + +If I insert an unknown project name, that's taken to mean that I want to select +a new project. In that case ~esy/read-project-by-name~ invokes +~read-directory-name~ to let me specify the root directory of that new project. +Most of my project directories live under ~~/checkouts/~, so the prompt for the +new project's root directory starts from there. Moreover, the unknown project +name that I've inserted first is placed in the minibuffer right after +~~/checkouts/~, so it acts as a hint for further completion operations. + +For example, let's say I have a new Git repository that I've just cloned into +~~/checkouts/foobar/~. If I want to do some project-wide task with it, maybe +searching for a regular expression or starting a dedicated shell buffer, I can +hit ~C-x p p~ and type ~foobar RET~. Now I get the ~Project root directory:~ +prompt, and the initial input is already ~~/checkouts/foobar~, so I just press +~RET~ again and I'm there. + +But what if I can't remember exactly where I've cloned that new repo into, was +it ~foobar~ or ~foobaz~? Well, no problem. If I do ~fooba RET~ at the prompt +from ~C-x p p~ I get the same prompt for directory as before, except now the +initial input is ~~/checkouts/fooba~. Hitting ~TAB~ completes this to +~~/checkouts/foobar/~ and we're good to go. Contrast this behavior with how +~project-prompt-project-dir~ and ~project-prompt-project-name~ handle unknown +projects--they both simply say ~No match~ in response to ~C-x p p foobar RET~. + +* Conclusion + +Emacs's =project.el= got a cool new enhancement. It still isn't perfect, but it +is more /extensible/ than ever, which means I can better tailor it to my needs.