Tricks for Eshell

2021-09-05

Table of Contents

This post summarizes some of the Emacs Lisp functions I wrote for Eshell.

All codes

If one wants to download the codes in one go, then one can find all the codes in the git repository. But notice that file is not self-contained: one function durand-convert-youtube-video-to-url is defined in another file (search "(defun durand-convert-youtube-video-to-url" to find the definition).

Measure time of commands automatically

When executing commands in Eshell, sometimes it is convenient to know the exact time it took to execute that command. Since I might not always to remember to run the command as "time COMMAND", I thought it might be convenient to display this information automatically, if the execution time of the last command was longer than a fixed threshold.

We first need some variables to hold the execution time of the last command and the string to display.

(defvar-local eshell-current-command-start-time nil
  "The time of the start of the current command.")

(defvar-local eshell-prompt-time-string ""
  "The string displaying the time of the last command, if any.")

They are made local variables automatically so that multiple Eshell buffers won't interfere with each other's last-command-execution-time.

Then we define a function to store the starting time of the command in the variable.

(defun eshell-current-command-start ()
  (setq-local eshell-current-command-start-time (current-time)))

And then the function to calculate the elapsed time since the last command started and store the time in the variable, if it is greater than 10000 microseconds, i.e. one hundredth second.

(defun eshell-current-command-stop ()
  (cond
   ((timep eshell-current-command-start-time)
    (let* ((elapsed-time (time-since eshell-current-command-start-time))
           (elapsed-time-list (time-convert elapsed-time 'list))
           (elapsed-time-int (time-convert elapsed-time 'integer))
           (format-seconds-string
            (format-seconds "%yy %dd %hh %mm %ss%z" elapsed-time-int))
           (microseconds (caddr elapsed-time-list))
           (micro-str
            (cond ((or (>= elaped-time-int 1) (> microseconds 10000))
                   (format "%dμs" microseconds)))))
      (setq eshell-prompt-time-string
            (mapconcat #'identity
                       (delq nil (list format-seconds-string
                                       micro-str))
                       " ")))
    (setq eshell-current-command-start-time nil))))

Now we only have to add the functions to the respective hooks to start tracking commands.

(defun eshell-current-command-time-track ()
  (add-hook 'eshell-pre-command-hook #'eshell-current-command-start nil t)
  (add-hook 'eshell-post-command-hook #'eshell-current-command-stop nil t))

(defun eshell-start-track-command-time ()
  "Start tracking the time of commands."
  (cond
   ((derived-mode-p 'eshell-mode)
    (eshell-current-command-time-track)))
  (add-hook 'eshell-mode-hook #'eshell-current-command-time-track))

(defun eshell-stop-track-command-time ()
  (remove-hook 'eshell-pre-command-hook #'eshell-current-command-start t)
  (remove-hook 'eshell-post-command-hook #'eshell-current-command-stop t)
  (remove-hook 'eshell-mode-hook #'eshell-current-command-time-track))

I also add functions to start and stop tracking commands, in case sometimes I don't want to track the commands for a while.

But we are not displaying that information yet. I chose to display that information in the prompt. So I adviced the prompt-emitting function to do this.

One might wonder why not just define a function for eshell-prompt-function. The reason is simple: the text returned by that function will be highlighted by the same face in the function that inserts the prompt into Eshell buffers. That is why I chose to overwrite that function, in order to add my own face to highlight the timing information.

(defun durand-eshell-emit-prompt ()
  "Emit a prompt if eshell is being used interactively.
Add a time information at the beginning. -- Modified by Durand."
  (when (boundp 'ansi-color-context-region)
    (setq ansi-color-context-region nil))
  (run-hooks 'eshell-before-prompt-hook)
  (if (not eshell-prompt-function)
      (set-marker eshell-last-output-end (point))
    (let ((prompt (funcall eshell-prompt-function)))
      (and eshell-highlight-prompt
           (add-text-properties 0 (length prompt)
                                '(read-only t
                                  font-lock-face eshell-prompt
                                  front-sticky (font-lock-face read-only)
                                  rear-nonsticky (font-lock-face read-only))
                                prompt))
      (eshell-interactive-print
       (mapconcat #'identity
                  (delq
                   nil
                   (list
                    (cond ((> (length eshell-prompt-time-string) 0)
                           (propertize eshell-prompt-time-string
                                       'font-lock-face 'modus-themes-heading-1
                                       'read-only t
                                       'front-sticky '(font-lock-face read-only)
                                       'rear-nonsticky '(font-lock-face read-only))) )
                    prompt))
                  " "))
      (setq eshell-prompt-time-string "")))
  (run-hooks 'eshell-after-prompt-hook))

(advice-add #'eshell-emit-prompt :override #'durand-eshell-emit-prompt)

The face modus-themes-heading-1 can of course be substituted with other faces that one prefers.

Delete (parts of) buffer

This function literally deletes parts of the buffer, without doing anything else. So it does add an entry to the command history. And it does not delete the whole buffer, before inserting the prompt again. It does not touch the parts it does not delete.

One can bind that to a key. With numeric prefix arguments, it will preserve that many previous prompts.

(defun eshell-clear (num)
  "Deletes the buffer.
Do NUM times `eshell-previous-prompt' before deleting."
  (interactive
   (list (cond ((null current-prefix-arg) 0)
               ((prefix-numeric-value current-prefix-arg)))))
  (let ((inhibit-read-only t))
    (delete-region
     (point-min)
     (save-excursion
       (eshell-previous-prompt num)
       (line-beginning-position)))))

Substitution

I saw this functiaonlity from a manual of ZShell. It is a built-in shell command, which allows the user to substitute parts of the previous command and then run the substituted command.

At times this is quite convenient. For example, after playing a video by a command like play VIDEO.mkv, one can immediately delete the video file by calling r play=rm.

After I started using Eshell as my main shell, I missed this feature a lot. So I replicated it in Emacs Lisp. Since this command deals with parsing command line options, we use the built-in mechanism (eshell-eval-using-options) to do this.

(defun eshell/r (&rest args)
  "Replace the last command by the specifications in ARGS."
  (eshell-eval-using-options
   "r" args
   '((?h "help" nil nil "Print this help message")
     (?n "number" t last-number "Which last command to replace")
     :usage "[-n number] [replacement specifications...]
REPLACEMENT SPECIFICATIONS are pairs of the form MATCH=REPLACE.
This command will find the last command, or the last N-th command
if given the option -n, and replace any match of MATCH by
REPLACE."
     :preserve-args
     :parse-leading-options-only
     :show-usage)
   (eshell-r args last-number)))

This is just a wrapper of another function eshell-r. The purpose is to parse the options first and pass to the function eshell-r the arguments in a parsed and convenient form.

Worth noting is that with ":show-usage" if one calls "r" without arguments then the help string will be printed, which is nice in my opinion.

By the way, the function name eshell/r is a convention of Eshell: functions with the name "eshell/NAME" can be called in Eshell simply as NAME.

Then the function eshell-r is as follows.

(defun eshell-r (args &optional last-number)
  "Replace the LAST-NUMBER th command by ARGS.
ARGS are pairs of the form MATCH=REPLACE. This command will find
the last command, or the LAST-NUMBER-th command if LAST-NUMBER is
non-nil, and replace the first match of MATCH by REPLACE.

LAST-NUMBER is passed to `prefix-numeric-value': If it is nil,
then it means 1; if it is a minus sign, then it means -1; if it
is a cons cell, and its `car' is an integer, then it means its
`car'; if it is an integer, then it means that integer; and any
other value means 1."
  (let* ((last-number (prefix-numeric-value last-number))
         (args (mapcar (lambda (pair)
                         (split-string pair "="))
                       args))
         (nth-last-command (ring-ref eshell-history-ring last-number))
         temp)
    ;; Transform ARGS to the required format.

    ;; NOTE; Using `mapc' is recommended by the manual.
    (mapc
     (function
      (lambda (arg)
        (cond
         ((and (consp arg)
               (= (length arg) 2)
               (stringp (car arg))
               (stringp (cadr arg)))
          (setq temp (cons arg temp)))
         ((and (consp arg)
               (= (length arg) 1)
               (stringp (car arg)))
          (setq temp (cons
                      (list (caar temp)
                            (concat (cadar temp) (cons 32 nil) (car arg)))
                      (cdr temp))))
         ((user-error "Wrong specification: MATCH=REPLACE required, but got %S" arg)))))
     args)
    (setq args (nreverse temp))
    ;; Replace the command
    (save-match-data
      (while (consp args)
        (setq temp (car args))
        (cond
         ;; Cannot use `string-match-p' here.
         ((string-match (car temp) nth-last-command)
          (setq nth-last-command (replace-match
                                  (or (cadr temp) "")
                                  t nil nth-last-command))))
        (setq args (cdr args))))
    ;; Let the user know what the result of substitution is.
    (eshell-printn nth-last-command)
    ;; Check we don't do another r command.
    (cond
     ((and (/= (length nth-last-command) 0)
           (= (aref nth-last-command 0) ?r)
           (or (= (length nth-last-command) 1)
               (= (aref nth-last-command 1) 32)))
      (user-error "Repeating a repeating command. This is probably not what you want")))
    (eshell-command-result nth-last-command)))

There are rooms of improvements here: right now it does not accept recursive substitutions, that is to say, if the command you want to repeat is another repeating command, then this reports an error. I have not decided what to do in this case: should I substitute the strings in the previous repeating command, or should I collect the previous repeating arguments and substitute the more-previous command? The latter means the following.

echo hello World
# hello World
r hello=bonjour
# bonjour World
r World=le monde
# bonjour le monde

To implement this functionality, I would have to separate the substitution part out, so that I can recursively substitute commands. If anyone has some suggestions, welcome to contact me.

Also, currently spaces can appear in the replacement texts, i.e. after equal signs, without double quotes, but not in the matching texts. To have spaces in matching texts, one has to enclose the texts with double quotes, as follows.

echo hello world
# hello world
r "hello world"=bonjour le monde
# bonjour le monde

Jumping

This idea also came from my Zshell experience: this was adapted from a Zshell plugin. Basically it implements a shell-bookmark. So it can record certain locations as symbolic links stored in a fixed location, and then the user can jump to those stored locations by their symbolic names. What is better in Eshell is that we can use any completion framework to do this.

To be honest, I think this is not the best interaction. For example, one should not have to rely on Eshell to jump around, and can bind the command directly to keys. But the present configuration is convenient enough for me, so I did not bother to invent a new way to integrate the power of Emacs with this jumping behaviour fully. Again, suggestions or discussions are welcomed.

Before the actual implementation of "j" is presented, let's first see its complementary functions.

(defvar eshell-mark-directory (expand-file-name "~/.marks")
  "The directory that stores links to other directories.")

(defun eshell/mark (&rest args)
  "Add symbolic links to `eshell-mark-directory'.
The argument ARGS should be list of one string which names the
link name. If no argument is given, the base name of the current
directory is used."
  ;; (setq args (cons default-directory args))
  ;; (setq args (durand-eshell-delete-dups args :test #'string=))
  (setq args (cond
              ((consp args) (car (flatten-tree args)))
              ((file-name-nondirectory
                (directory-file-name
                 (file-name-directory default-directory))))))
  (eshell-command-result
   (format "ln -sf \"%s\" \"%s\""
           default-directory (expand-file-name args eshell-mark-directory))))

This command adds the current directory as a shell bookmark. The use can give an argument to name the bookmark. If not given, the base name of the current directory will be used.

Then we also add a function to display all bookmarks in a pretty way.

(defun eshell/marks ()
  "List all symbolic links."
  (let* ((dirs (directory-files eshell-mark-directory nil
                                (rx-to-string '(seq bos (or (not ".")
                                                         (seq "." (not ".")))))))
         (max-length (apply #'max (mapcar #'length dirs))))
  (mapconcat
   (function
    (lambda (mark)
      (concat (propertize mark 'font-lock-face 'modus-themes-mark-symbol)
              (make-string (- max-length (length mark)) #x20)
              " -> "
              (file-truename (expand-file-name mark eshell-mark-directory)))))
   dirs "\n")))

The face modus-themes-mark-symbol can be substituted as well.

Now we write a wrapper for parsing command line options.

(defun eshell/j (&rest args)
  "Implementation of `j'.
See `eshell-j' for the actual functionality."
  (eshell-eval-using-options
   "j" args
   '((?r "recent" 'exclusive use-recent-p "Find in recent directories instead of symbolic links.")
     (?a "all" nil use-recent-p "Find both in recent directories and in symbolic links.")
     (?h "help" nil nil "Print this help message.")
     :usage "[-hra] [short-cut]")
   (eshell-j args use-recent-p)))

Notice that this time pressing "j" alone will not print the help string; instead it will use completing-read to ask for a choice, so that we can view all choices easily.

One last thing is a function to delete duplicates. Well, there is already a built-in one: delete-dups, but that only uses equal as the function to test if two objects are equal. So, out of fun, I wrote this to use other functions as equality predicates. If you want you can also use delete-dups here, since equal can handle string equality well, I guess.

(defun durand-eshell-delete-dups (sequence &rest args)
  "Return a copy of SEQUENCE with duplicate elements removed.
ARGS should be a property list specifying tests and keys.

If the keyword argument TEST is non-nil, it should be a function
with two arguments which tests for equality of elements in the
sequence. The default is the function `equal'.

If the keyword argument KEY is non-nil, it should be a function
with one argument which returns the key of the element in the
sequence to be compared by the test function. The default is the
function `identity'.

Note that this function is not supposed to change global state,
including match data, so the functions in TEST and KEY are
supposed to leave the global state alone as well.

\(fn SEQUENCE &key TEST KEY)"
  (declare (pure t) (side-effect-free t))
  (let* ((len (length sequence))
         (temp-obarray (obarray-make len))
         (valid-key-num (+ (cond ((plist-member args :key) 1) (0))
                           (cond ((plist-member args :test) 1) (0))))
         (key (cond ((cadr (plist-member args :key)))
                    (#'identity)))
         (test-fn (cond ((cadr (plist-member args :test)))
                        (#'equal)))
         found-table result)
    (cond ((or (= (mod (length args) 2) 1)
               (> (length args) (* 2 valid-key-num)))
           (user-error "Invalid keyword arguments.  Only :key and :test are allowed, but got %S"
                       args)))
    ;; Note: This just puts a property to the symbol.
    (define-hash-table-test 'durand-delete-dups-test
      test-fn (function (lambda (obj) (intern (format "%S" obj) temp-obarray))))
    (setq found-table (make-hash-table :test 'durand-delete-dups-test :size len))
    (mapc
     (function
      (lambda (element)
        (cond ((gethash (funcall key element) found-table))
              ;; Abuse the fact that `puthash' always returns VALUE.
              ((puthash (funcall key element) t found-table)
               (setq result (cons element result))))))
     sequence)
    (nreverse result)))

Finally the actual implementation:

(defun eshell-j (&optional short-cut use-recent-p)
  "Jump to SHORT-CUT.
Where this jumps to is determined by the symbolic links in the
directory `eshell-mark-directory'.  If USE-RECENT-P is non-nil, then
also include recent directories in the list of candidates. Moreover,
if USE-RECENT-P is 'exclusive, then only list the recent
directories as candidates, unless there are no recent
directories, in which case it falls back to use the marks as the
candidates."
  (let* ((mark-directory eshell-mark-directory)
         (short-cut (eshell-flatten-and-stringify short-cut))
         (links (delq nil
                      (mapcar
                       (function
                        (lambda (name)
                          (cond
                           ((or (string= "." (file-name-nondirectory name))
                                (string= ".." (file-name-nondirectory name)))
                            nil)
                           (name))))
                       (directory-files mark-directory t short-cut t))))
         (candidates (cond
                      ((and use-recent-p
                            (not (eq use-recent-p 'exclusive))
                            (ring-p eshell-last-dir-ring)
                            (not (ring-empty-p eshell-last-dir-ring)))
                       (append (mapcar (function
                                        (lambda (file)
                                          (cons (file-name-nondirectory file)
                                                file)))
                                       links)
                               (cond
                                ((string-match-p short-cut mark-directory)
                                 (list (cons mark-directory mark-directory))))
                               (mapcar (function (lambda (file) (cons file file)))
                                       (ring-elements eshell-last-dir-ring))))
                      ((and use-recent-p
                            (eq use-recent-p 'exclusive)
                            (ring-p eshell-last-dir-ring)
                            (not (ring-empty-p eshell-last-dir-ring)))
                       (mapcar (function (lambda (file) (cons file file)))
                               (ring-elements eshell-last-dir-ring)))
                      ((append
                        (mapcar (function
                                 (lambda (file)
                                   (cons (file-name-nondirectory file)
                                         file)))
                                links)
                        (cond
                         ((string-match-p short-cut mark-directory)
                          (list (cons mark-directory mark-directory))))))))
         ;; Delete duplicate items
         (candidates
          (durand-eshell-delete-dups
           candidates
           :test #'string=
           ;; In Haskell this woule be a simple function composition.
           :key (function (lambda (ls) (file-truename (cdr ls)))))))
    (cond
     ((null candidates)
      (user-error "No candidates matching %s found" short-cut))
     ((null (cdr candidates))
      ;; Only one candidate
      (eshell/cd (file-truename (cdar candidates))))
     ((eshell/cd (file-truename
                  (cdr
                   (assoc
                    (let ((completion-regexp-list
                           (cons short-cut completion-regexp-list)))
                      (completing-read "Choose a link: " candidates nil t))
                    candidates #'string=))))))))

All original content is licensed under the free copyleft license CC BY-SA .

Author: JSDurand

Email: durand@jsdurand.xyz

Date: 2021-09-05 Dim 16:44:00 CST

GNU Emacs 28.2.50 of 2022-12-05 (Org mode 9.5.5)

Validate