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.


  1. 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. ↩︎

  2. Other functions accepting hooks are chpwd() or precmd()↩︎