A few years ago I wrote about a simple utility I wrote for myself to copy the “current location” in the project (that is, the filename and optionally a line number) to the system clipboard so that I can yank (paste) it in e.g. my company chat system (which I have to use outside Emacs). I’m using it all the time, and it’s very useful, but it’s still a bit limited.
Here’s one gripe I had with it. The full path is great when there is more than one file with the same name in the project. That happens, but not really often. I decided that only having the base name of the file (that is, the name without the directory part) would be more useful if that base name is unique throughout the project.
Getting the list of all files in the current project should be fairly easy – that’s what project-find-file
does. I jumped to project.el
to see if I can find the functions I need, and it turned out that indeed I can. (While searching for that, I realized that I need to finally read about Elisp’s generic functions to be able to better understand what is going on in the code that uses them.) After a few minutes of poking around I came up with this simple form: (project-files (project-current))
. What I needed next was to check if a given basename is unique in the project. This can be accomplished pretty easily, too – it is enough to use file-name-nondirectory
to strip the path from the names of all the files in the project and count the files with the given base name.
(defun copy-current-location (no-line-number) "Show the current location and put it into the kill ring. Here, \"location\" means the filename and line number (after a colon). Use the filename relative to the parent of the current VC root directory, so it starts with the main project dir. With \\[universal-argument], the line number is omitted." (interactive "P") (let* ((file-name (file-relative-name (or buffer-file-name dired-directory) (file-name-concat (vc-root-dir) ".."))) (file-base-name (file-name-nondirectory file-name)) (count (length (cl-remove-if-not (lambda (filename) (string= file-base-name (file-name-nondirectory filename))) (project-files (project-current))))) (line-number (line-number-at-pos nil t)) (location (format (if (or no-line-number (eq major-mode 'dired-mode)) "%s" "%s:%s") (if (= count 1) file-base-name file-name) line-number))) (kill-new location) (message location)))
I admit that it might be even better if instead of the full path for non-unique file names, only the shortest prefix making it unique would be added – but given how rarely non-unique filenames happen in my case, I think it’s not worth it to implement this.
After using this code for some time, I noticed it still has one flaw, though. The whole trick with only copying the base name does not work with directories. Also, in the (perhaps rare, but still possible) case where the project contains both a file and a directory with the same name, the full path is not used. Of course, this is because project-files
does not include directories in its output.
I lurked in project.el source for a bit more and found this nice trick:
(delete-dups (mapcar #'file-name-directory all-files))
where all-files
is the result of evaluating (project-files
(project-current))
.
It takes the list of all files in the project, takes the directory part of each of them, and removes duplicates (which will inevitably pop up for any directory containing more than one file). It is still not ideal. One issue is that it won’t find any empty directory. This is not really a problem, since empty directories are pretty rare nowadays – Git also skips them, so if someone really insists on having an “empty” directory in some Git-managed project, the convention is to put an empty files called .gitkeep
in it and commit it. A bigger problem is that if the project happens to have somewhere a file and a directory of the same name, they still will be treated differently, since file-name-directory
leaves the slash at the end (at least on GNU/Linux), and hence no full path will be used. The solution is to apply directory-file-name
, which (again, on GNU/Linux) just removes the trailing slash (if present). (I must mention here that I find the naming if these two functions a bit funny, although it is perfectly logical.) Since Emacs Lisp – a bit surprisingly – does not seem to have a general function composition operator, I decided to use a lambda:
(delete-dups (append all-files (mapcar (lambda (name) (directory-file-name (file-name-directory name))) all-files)))
Of course, I could use Magnar Sveen’s dash and its -compose function, too, but I preferred to avoid an external dependency here.
So, this this the final version of my code.
(defun copy-current-location (no-line-number) "Show the current location and put it into the kill ring. Here, \"location\" means the filename and line number (after a colon). Use the filename relative to the parent of the current VC root directory, so it starts with the main project dir. With \\[universal-argument], the line number is omitted." (interactive "P") (let* ((file-name (file-relative-name (or buffer-file-name dired-directory) (file-name-concat (vc-root-dir) ".."))) (file-base-name (file-name-nondirectory file-name)) (all-files (project-files (project-current))) (all-files-and-dirs (delete-dups (append all-files (mapcar (lambda (name) (directory-file-name (file-name-directory name))) all-files)))) (count (length (cl-remove-if-not (lambda (filename) (string= file-base-name (file-name-nondirectory filename))) all-files-and-dirs))) (line-number (line-number-at-pos nil t)) (location (format (if (or no-line-number (eq major-mode 'dired-mode)) "%s" "%s:%s") (if (= count 1) file-base-name file-name) line-number))) (kill-new location) (message location)))
As usual, let me finish with a mention of my book, Hacking your way around in Emacs, which is a short Elisp textbook meant as a “next step” after the great Introduction to programming in Emacs Lisp by the late Robert J. Chassell, which is an excellent and very gentle introduction to Emacs Lisp. If you’d like to learn to write little (and possibly even larger!) utilities like this one, both books may be a valuable resource – check them out! (Introduction to programming in Emacs Lisp is free and Hacking your way around in Emacs is reasonably priced, with the option of getting a refund if it turns out to be not for you.)
Happy hacking!