Directory bookmarks with Zsh
Vincent Bernat
9-minute read
Filed under
Several projects implement directory bookmarks for your favorite
shell. But they all share a limitation: they only enhance the cd command. You
cannot use a bookmark in an arbitrary command.
Update (2015-02)
My initial implementation with Zsh used dynamic named directories. People on Twitter pointed out a simpler approach. The article has been updated to reflect that. As a side note, you can also use shell variables.1
Static named directories#
Zsh comes with a lesser-known feature called static named directories.
You declare them with the hash builtin and refer to them by prepending ~:
$ hash -d -- -lldpd=/home/bernat/code/deezer/lldpd $ echo ~-lldpd/README.md /home/bernat/code/deezer/lldpd/README.md $ head -n1 ~-lldpd/README.md lldpd: implementation of IEEE 802.1ab (LLDP)
Because ~-lldpd is substituted during filename expansion, you can use it in
any command like a regular directory, as shown above. The - prefix avoids
collisions with home directories.
Bookmarks live in a dedicated directory, $MARKPATH. Each bookmark is a
symbolic link to the target directory: for example, ~-lldpd expands to
$MARKPATH/lldpd, which points to the appropriate directory. Assuming you have
populated $MARKPATH with some links, here is how to register bookmarks:
for link ($MARKPATH/*(N@)) { hash -d -- -${link:t}=${link:A} }
You also get completion and prompt expansion for free:
$ pwd /home/bernat/code/deezer/lldpd/src/lib $ echo ${(%):-%~} ~-lldpd/src/lib
The last step is to manage bookmarks without manually adding or removing
symbolic links. The following bookmark() function displays existing bookmarks
when called without arguments, removes a bookmark when called with -d, or adds
the current directory as a bookmark otherwise.
bookmark() { if (( $# == 0 )); then # When no arguments are provided, just display existing # bookmarks for link in $MARKPATH/*(N@); do local markname="$fg[green]${link:t}$reset_color" local markpath="$fg[blue]${link:A}$reset_color" printf "%-30s -> %s\n" $markname $markpath done else # Otherwise, we may want to add a bookmark or delete an # existing one. local -a delete zparseopts -D d=delete if (( $+delete[1] )); then # With `-d`, we delete an existing bookmark command rm $MARKPATH/$1 else # Otherwise, add a bookmark to the current # directory. The first argument is the bookmark # name. `.` is special and means the bookmark should # be named after the current directory. local name=$1 [ $name == "." ] && name=${PWD:t} ln -s $PWD $MARKPATH/$name fi fi }
Find the complete version on GitHub.
Dynamic named directories#
Another, more complex, way to achieve the same result is dynamic named directories. I initially used this solution but it is far more complex. This section exists for historical reasons only. You can find the complete implementation in my Git repository.
During filename expansion, Zsh passes a ~ followed by a string in square
brackets to the zsh_directory_name() function, which replies with a directory
name. This feature can implement directory bookmarks:
$ cd ~[@lldpd] $ pwd /home/bernat/code/deezer/lldpd $ echo ~[@lldpd]/README.md /home/bernat/code/deezer/lldpd/README.md $ head -n1 ~[@lldpd]/README.md lldpd: implementation of IEEE 802.1ab (LLDP)
Like static named directories, because ~[@lldpd] is substituted during
filename expansion, you can use it in any command like a regular directory.
Basic implementation#
Bookmarks still live in a dedicated directory, $MARKPATH, as symbolic links.
Here is the core implementation:
_bookmark_directory_name() { emulate -L zsh # ❶ setopt extendedglob case $1 in n) [[ $2 != (#b)"@"(?*) ]] && return 1 # ❷ typeset -ga reply reply=(${${:-$MARKPATH/$match[1]}:A}) # ❸ return 0 ;; *) return 1 ;; esac return 0 } add-zsh-hook zsh_directory_name _bookmark_directory_name
zsh_directory_name() accepts hooks:2 instead of defining it directly,
we define another function and register it as a hook with add-zsh-hook.
The hook handles different situations. The first is transforming a dynamic
name into a regular directory name. In this case, the first parameter is n
and the second is the dynamic name.
In ❶, emulate restores the pristine behavior of Zsh and ensures that options
set within the function do not leak outside. The function can then be reused
safely in another environment.
In ❷, we check that the dynamic name starts with @ followed by at least one
character. Otherwise, we declare we don’t know how to handle it and another hook
gets a chance to act. (#b) is a globbing flag that activates backreferences
for parenthesized groups. When a match is found, it is stored in the $match
array.
In ❸, we build the reply. We could have returned $MARKPATH/$match[1], but to
hide the symbolic link mechanism, we use the A modifier to resolve symbolic
links. Zsh allows nested substitutions, so you can apply modifiers and flags
on anything. ${:-$MARKPATH/$match[1]} is a common trick to turn
$MARKPATH/$match[1] into a parameter substitution and apply the A modifier
on it.
Completion#
Zsh can also complete dynamic directory names. The completion system calls
the hook function with c as the first argument.
_bookmark_directory_name() { # […] case $1 in c) # Completion local expl local -a dirs dirs=($MARKPATH/*(N@:t)) # ❶ dirs=("@"${^dirs}) # ❷ _wanted dynamic-dirs expl 'bookmarked directory' compadd -S\] -a dirs return ;; # […] esac # […] }
First, in ❶, we build a list of possible bookmarks. In *(N@:t), N@ is a
glob qualifier: N returns nothing if there is no match (instead of an error)
while @ only returns symbolic links. t is a modifier that removes all
leading pathname components. This is equivalent to basename or
${something##*/} in POSIX shells but plays nicely with glob expressions.
In ❷, we just add @ before each bookmark name. If we have b1, b2 and b3
as bookmarks, ${^dirs} expands to {b1,b2,b3} and therefore "@"${^dirs}
expands to the (@b1 @b2 @b3) array.
The result then feeds into the completion system.
Prompt expansion#
Many people display the current directory name in their prompt. It would be nice to show the bookmark name instead of the full path when inside a bookmarked directory. That’s also possible!
$ pwd /home/bernat/code/deezer/lldpd/src/lib $ echo ${(%):-%~} ~[@lldpd]/src/lib
The prompt expansion system calls the hook function with d as the first
argument and the filename to transform.
_bookmark_directory_name() { # […] case $1 in d) local link slink local -A links for link ($MARKPATH/*(N@)) { links[${#link:A}$'\0'${link:A}]=${link:t} # ❶ } for slink (${(@On)${(k)links}}) { link=${slink#*$'\0'} # ❷ if [[ $2 = (#b)(${link})(|/*) ]]; then typeset -ga reply reply=("@"${links[$slink]} $(( ${#match[1]} )) ) return 0 fi } return 1 ;; # […] esac # […] }
Okay. This is black Zsh wizardry. Feel free to skip the explanation. It is complex because we want to substitute the most specific bookmark, hence the need to sort bookmarks by target path length.
In ❶, we build the associative array $links by iterating over each symbolic
link ($link) in $MARKPATH. The goal is to map a target directory to its
bookmark name. But we need to iterate from the longest to the shortest key. To
achieve that, we prepend each key with its length.
Remember, ${link:A} is the absolute path with symbolic links resolved.
${#link:A} is the length of this path. We concatenate the length with the
directory name, using $'\0' as a separator because it is the only safe
character for this purpose. The result maps to the bookmark name.
The second loop iterates over the keys of $links (using the k parameter flag
in ${(k)links}). These keys are turned into an array (@ parameter flag) and
sorted numerically in descending order (On parameter flag). Since the keys are
directory names prefixed by their lengths, the first match is the longest one.
In ❷, we extract the directory name from the key by stripping the length and
null character prefix. Then, we check if the extracted directory name matches
the provided filename. Again, (#b) activates backreferences. With extended
globbing, we can use the “or” operator, |.
When the filename matches the directory name exactly or is somewhere deeper, we build the reply: an array whose first element is the bookmark name and the second is the untranslated part of the filename.
Easy typing#
Typing ~[@ is cumbersome. Fortunately, the Zsh line editor supports custom
bindings. The following snippet substitutes @@ (if typed without a pause) with
~[@:
vbe-insert-bookmark() { emulate -L zsh LBUFFER=${LBUFFER}"~[@" } zle -N vbe-insert-bookmark bindkey '@@' vbe-insert-bookmark
Combined with the autocd option and completion, jumping to a bookmarked
directory becomes effortless.