Directory bookmarks with Zsh

Vincent Bernat

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.