Let Helm Support ZSH-like Path Expansion

1 What is it and Why

Helm is awesome. I can open files very quickly in Emacs using helm. But I still miss one feature from ZShell: the path expansion. For example, if I want visit some very deeply nested directory, say, /home/qjp/foo/bar/what/the/hell, I type following command:

cd /h/q/f/b/w/t/h

Then I hit TAB, zsh will list all the possible expansions and I can select one from them. Sounds familiar? Yes, if you use helm, helm will list all the possible completions for you and you can select one of them. However, the problem is that helm DOES NOT support zsh-like path expansion, and the author won't add this feature to helm (See here).

So in the previous example, using helm, you have to type some letters, select the candidate and expand to the selection. Since there are multiple levels of directories, you have to do this multiple times until you finally get to where you want. This is so inefficient and way too teeeeeedious! I don't know why I have to do such stupid things if I've already known the structures of the directories very well (this is my computer anyway) and I know exactly where I wanna go (Yeah, the current behavior of helm is useful when you are not sure where you want to go or the path is not that deeply nested).

For example, I want to open the haskell-mode.el source code file I downloaded from MELPA. I know it must be inside ~/.emacs.d/, then elpa, then haskell-mode-whatever, and then haskell-mode.el. To do that in normal helm, I need to type ~/, some letters and movements to select .emacs.d, enter the directory, repeat the previous procedure until I finally get to haskell-mode.el. Instead of doing all the tedious type-move-select thing, I would like to type ~/.e/e/h/h, then tab, select the result and hit enter. Done. Much fewer keystrokes required.

2 Elisp to the rescue

However, it is Emacs, we can customize it! I don't know much about the implementation of helm, but after about one hour's hack, I figured out a working patch for helm. This is what I add to my Emacs config file (EDIT: You have to enable the lexical binding to make the following code work)

(with-eval-after-load 'helm-files
  ;; Let helm support zsh-like path expansion.
  (defvar helm-ff-expand-valid-only-p t)
  (defvar helm-ff-sort-expansions-p t)
  (defvar helm-ff-ignore-case-p t)
  (defun helm-ff--generate-case-ignore-pattern (pattern)
    (let (head (ci-pattern ""))
      (dotimes (i (length pattern) ci-pattern)
        (setq head (aref pattern i))
        (cond
         ((and (<= head ?z) (>= head ?a))
          (setq ci-pattern (format "%s[%c%c]" ci-pattern (upcase head) head)))
         ((and (<= head ?Z) (>= head ?A))
          (setq ci-pattern (format "%s[%c%c]" ci-pattern head (downcase head))))
         (:else
          (setq ci-pattern (format "%s%c" ci-pattern head)))))))
  (defun helm-ff-try-expand-fname (candidate)
    (let ((dirparts (split-string candidate "/"))
          valid-dir
          fnames)
      (catch 'break
        (while dirparts
          (if (file-directory-p (concat valid-dir (car dirparts) "/"))
              (setq valid-dir (concat valid-dir (pop dirparts) "/"))
            (throw 'break t))))
      (setq fnames (cons candidate (helm-ff-try-expand-fname-1 valid-dir dirparts)))
      (if helm-ff-sort-expansions-p
          (sort fnames
                (lambda (f1 f2) (or (file-directory-p f1)
                                (not (file-directory-p f2)))))
        fnames)))

  (defun helm-ff-try-expand-fname-1 (parent children)
    (if children
        (if (equal children '(""))
            (and (file-directory-p parent) `(,(concat parent "/")))
          (when (file-directory-p parent)
            (apply 'nconc
                   (mapcar
                    (lambda (f)
                      (or (helm-ff-try-expand-fname-1 f (cdr children))
                          (unless helm-ff-expand-valid-only-p
                            (and (file-directory-p f)
                                 `(,(concat f "/" (mapconcat 'identity
                                                             (cdr children)
                                                             "/")))))))
                    (directory-files parent t
                                     (concat "^"
                                             (if helm-ff-ignore-case-p
                                                 (helm-ff--generate-case-ignore-pattern
                                                  (car children))
                                               (car children))))))))
      `(,(concat parent (and (file-directory-p parent) "/")))))

  (defun qjp-helm-ff-try-expand-fname (orig-func &rest args)
    (let* ((candidate (car args))
           (collection (helm-ff-try-expand-fname candidate)))
      (if (and (> (length collection) 1)
               (not (file-exists-p candidate)))
          (with-helm-alive-p
            (when (helm-file-completion-source-p)
              (helm-set-pattern
               (helm-comp-read "Expand Path to: " collection :allow-nest t))))
        (apply orig-func args))))

  (advice-add 'helm-ff-kill-or-find-buffer-fname :around #'qjp-helm-ff-try-expand-fname))

And here is how it looks:

Helm support ZSH-like path expansions

You can set helm-ff-expand-valid-only-p to t to have only valid paths in the completions. Another customization option is that you can set helm-ff-sort-expansions-p to t to make sure the valid paths will appear in the front of the completions.

3 Disclaimer

I only tested with Emacs 24.5/25.1 on Arch Linux. :-)

Junpeng Qiu 17 November 2015
blog comments powered by Disqus