Directory bookmarks with Zsh
Vincent Bernat
There are numerous projects to implement directory
bookmarks in your favorite shell. An inherent limitation of these
implementations is they being only an “enhanced” cd
command: you
cannot use a bookmark in an arbitrary command.
Update (2015-02)
My initial implementation with Zsh was using dynamic named directories. I have been pointed on Twitter that there is a simpler way to implement bookmarks. The article has been updated to reflect that. As a side note, it is also possible to just use shell variables.1
Zsh comes with a not well-known feature called static named
directories. They are declared with the hash
builtin and can be
referred by prepending ~
to them:
$ 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 file name expansion, it is
possible to use it in any command like a regular directory, like shown
above. The -
prefix is only here to avoid collision with home
directories.
Bookmarks are kept into a dedicated directory, $MARKPATH
. Each
bookmark is a symbolic link to the target directory: for example,
~-lldpd
should be expanded to $MARKPATH/lldpd
which points to the
appropriate directory. Assuming that you have populated $MARKPATH
with some links, here is how bookmarks are registered:
for link ($MARKPATH/*(N@)) { hash -d -- -${link:t}=${link:A} }
You also automatically get completion and prompt expansion:
$ pwd /home/bernat/code/deezer/lldpd/src/lib $ echo ${(%):-%~} ~-lldpd/src/lib
The last step is to manage bookmarks without adding or removing
symbolic links manually. The following bookmark()
function will display the
existing bookmarks when called without arguments, will remove a
bookmark when called with -d
or add 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 thing is using dynamic named directories. I was initially using this solution but it is far more complex. This section is only here for historical reason. You can find the complete implementation in my Git repository.
During file name expansion, a ~
followed by a string in square
brackets is provided to the zsh_directory_name()
function which will
eventually reply with a directory name. This feature can be used to
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 for static named directories, because ~[@lldpd]
is
substituted during file name expansion, it is possible to use it in
any command like a regular directory.
Basic implementation#
Bookmarks are still kept into a dedicated directory, $MARKPATH
and
are still symbolic links. Here is how the core feature is implemented:
_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()
is a function accepting hooks:2 instead of
defining it directly, we define another function and register it as a hook
with add-zsh-hook
.
The hook is expected to handle different situations. The first one is
to be able to transform a dynamic name into a regular directory
name. In this case, the first parameter of the function is n
and the
second one is the dynamic name.
In ❶, the call to emulate
will restore the pristine behavior of
Zsh and also ensure that any option set in the scope of the function
will not have an impact 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. Another hook will get the chance to do something. (#b)
is a
globbing flag. It activates backreferences for parenthesised
groups. When a match is found, it is stored as an array, $match
.
In ❸, we build the reply. We could have just returned
$MARKPATH/$match[1]
but to hide the symbolic link mechanism, we use
the A
modifier to ask Zsh to resolve symbolic links if
possible. Zsh allows nested substitutions. It is therefore possible
to use modifiers and flags on anything. ${:-$MARKPATH/$match[1]}
is
a common trick to turn $MARKPATH/$match[1]
into a parameter
substitution and be able to apply the A
modifier on it.
Completion#
Zsh is also able to ask for completion of a dynamic directory
name. In this case, 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 create a list of possible bookmarks. In *(N@:t)
,
N@
is a glob qualifier. N
allows us to not return nothing if
there is no match (otherwise, we would get an error) while @
only
returns symbolic links. t
is a modifier which will remove all
leading pathname components. This is equivalent to use basename
or
${something##*/}
in POSIX shells but it plays nice 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 is then fed into the completion system.
Prompt expansion#
Many people put the name of the current directory in their prompt. It would be nice to have the bookmark name instead of the full name when we are below 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 first
argument and the file name 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 some black Zsh wizardry. Feel free to skip the explanation. This is a bit complex because we want to substitute the most specific bookmark, hence the need to sort bookmarks by their target lengths.
In ❶, the associative array $links
is created by iterating on each
symbolic link ($link
) in the $MARKPATH
directory. The goal is to
map a target directory with the matching bookmark name. However, we
need to iterate on this map 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 of the target directory with the target directory name and
use $'\0'
as a separator because this is the only safe character for
this purpose. The result is mapped to the bookmark name.
The second loop is an iteration on the keys of the associative array
$links
(thanks to the use of 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 will be the longest one.
In ❷, we extract the directory name from the key by removing the
length and the null character at the beginning. Then, we check if the
extracted directory name matches the file name we have been
provided. Again, (#b)
just activates backreferences. With extended
globbing, we can use the “or” operator, |
.
When either the file name matches exactly the directory name or is somewhere deeper, we create the reply which is an array whose first member is the bookmark name and the second member is the untranslated part of the file name.
Easy typing#
Typing ~[@
is cumbersome. Hopefully, Zsh line editor can be extended
with additional bindings. The following snippet will substitute @@
(if typed without a pause) by ~[@
:
vbe-insert-bookmark() { emulate -L zsh LBUFFER=${LBUFFER}"~[@" } zle -N vbe-insert-bookmark bindkey '@@' vbe-insert-bookmark
In combination with the autocd
option and completion, it is quite easy to jump to a bookmarked directory.
-
For example:
$ 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)
The drawback is that you don’t have a separate namespace for your bookmarks. You can still use a special prefix for that. Also, no prompt expansion. ↩︎
-
Other functions accepting hooks are
chpwd()
orprecmd()
. ↩︎