Rewriting z from scratch
z is a tool that will remember all the directories you are visiting when using your terminal, and then make it possible to jump around those directories quickly.
Let’s try and rewrite this functionality from scratch, maybe we’ll learn a few things this way.
Motivation #
I started using z
about one year ago.
It worked fine except for one thing.
Let’s say I have two directories matching spam
, /path/to/spam-egss
, and
/other/path/to/eggs-with-spam
.
z
will compute a score for each directory that depends on how often and how
recently it was accessed.
So, in theory:
a directory that has low ranking but has been accessed recently will quickly have higher rank than a directory accessed frequently a long time ago. (excerpt of
z
’s README, emphasis is mine)
But in practice, if you start working from spam-eggs
to eggs-with-spam
you will get the wrong answer for z spam
a few times, (“quickly” does not
means “instantly”)
Also, because the algorithm uses the date of access, you cannot (easily) predict which directory it will choose.
Step one: choose a database format #
z
uses a database that looks like this
/other/path/to/eggs-with-spam|24|1495372880
/path/to/spam-eggs|4|1495372491
...
There’s the path, the number of times it was accessed and the timestamp of last visit.
We’ll use json
, and since we want an algorithm that does not
depend of the time, we’ll only store the number of accesses:
{
"/other/path/to/eggs-with-spam": 24,
"/path/to/spam-eggs": 4,
...
}
Why json
? Because it’s quite easy to read and write (including pretty print)
in any language, while still being editable by humans.
It still is possible to add new data should we need to.
Step two: decide how to handle non-existing directories #
Let’s say you work in /tmp/foo
, and no other directory is called foo
.
You also create the directories /tmp/foo/src
and /tmp/foo/include
.
With z
, once the three paths, /tmp/foo
, /tmp/foo/src/
and
/tmp/foo/include
are stored in the database, they will stay there for ever.
This means that if you remove /tmp/foo
, z foo
will still try to go into the
non-existing directory. But, if you re-create /tmp/foo
later on, z foo
will
work again.
In our rewrite, we’ll deal with this situation an other way:
- One, if we can’t
cd
to a directory, we’ll remove it from the database immediately - Two, we’ll make it possible to explicitly “purge” the database: in our example, that means removing all three paths in one step. To do this, we’ll look at every path in the database and remove those which no longer exist.
Step three: write a command line tool #
I decided to name the tool cwd-history
, and to use an command line syntax
looking like git
, with several possible “verbs” for the various actions:
cwd-history list
: to display the paths in the correct ordercwd-history add PATH
: add a path to the databasecwd-history remove PATH
: to remove a path from the databasecwd-history edit
: to edit the json file directly (could become handy)cwd-history clean
: to remove non-existing paths from the database
The code is on github if you want to take a look.
Some notes:
def get_db_path():
zsh_share_path = os.path.expanduser("~/.local/share/zsh")
os.makedirs(zsh_share_path, exist_ok=True)
-
We honor XDG standard (in reality we should also check for the
XDG_DATA_HOME
environment variable, but this is better than polluting$HOME
). -
We use the
exist_ok
1 argument foros.makedirs
, so that the command does not fail if the directory already exists.
def add_to_db(path):
path = os.path.realpath(path)
- We use
os.path.realpath
to make sure all the symlinks are resolved. This means that we won’t have duplicated paths in our database.
def clean_db():
cleaned = 0
entries = read_db()
for path in list(entries.keys()):
if not os.path.exists(path):
cleaned += 1
del entries[path]
if cleaned:
print("Cleaned", cleaned, "entries")
write_db(entries)
else:
print("Nothing to do")
- We try to provide as many information as possible in just one line, by displaying either the number of directories that were cleaned, or that nothing was done. Listing all the paths that were removed would be too verbose, and if we did not display anything we would never be sure the command really worked.
import operator
def list_db():
entries = read_db()
sorted_entries = sorted(entries.items(),
key=operator.itemgetter(1))
print("\n".join(x[0] for x in sorted_entries))
- We use
operator.itemgetter
as a shortcut tolambda x: x[1]
- Thus, we sort the paths by the number of times they were accessed.
- We only display the paths, each of them separated by one line. This will make
the output of
cwd-history list
really easy to parse. (We could add a--verbose
option to display the full database data for instance)
Step four: hook into zsh #
We want to call cwd-history add $(cwd)
every time zsh
changes the current
working directory.
This is done by writing a function and add it to a special array:
function register_cwd() {
cwd-history add "$(pwd)"
}
typeset -gaU chpwd_functions
chpwd_functions+=register_cwd
- Note how
chpwd_functions
is a zsh array, so we have to usetypeset
. We call it with-g
becausechpwd_functions
is a global value, and-U
to make sure the list contains no duplicates. I’m not sure what-a
is for, sorry.
Step five: filtering results #
Instead of trying to guess the best result, we’ll let the user choose by hooking
into fzf
.
I already talked about fzf
on a previous article. The gist of it is that you
can pass any command as input to fzf
, and let the user interactively select
one result from the list.
The implementation looks like this:
cwd_list=$(cwd-history list)
ret="$(echo $cwd_list| fzf --no-sort --tac --query=${1})"
cd "${ret}"
if [[ $? -ne 0 ]]; then
cwd-history remove "${ret}"
fi
Notes:
-
We use
fzf
with--query
, so that you can either typez foo
, or justz
, and only after type thefoo
pattern infzf
’s window -
Since the most likely answers are at the bottom of the
cwd-history list
command, we usefzf
with the--tac
option2. We also need tellfzf
to not sort the input beforehand. -
As explained earlier, we remove the path from the database if we can’t
cd
into it. (This also covers the case where we are lacking permissions to visit the folder, by the way)
Step six: from the shell to neovim and vice-versa #
One last thing. Since I do most of my editing in neovim, I’m always looking for ways to achieve similar behaviors in my shell and in my editor.
So let’s see how we can transfer information about visited directories from one tool to an other.
From neovim to the shell #
This is kind of a ugly hack.
First, I add a auto command to write neovim’s current directly into a hard-coded
file in /tmp/
:
" Write cwd when leaving
function! WriteCWD()
call writefile([getcwd()], "/tmp/nvim-cwd")
endfunction
autocmd VimLeave * silent call WriteCWD()
And then, I wrap the call to neovim
in a function that reads the content of
the file and then calls cd
, but only if neovim
exited successfully.
# Change working dir when Vim exits
function neovim_wrapper() {
nvim $*
if [[ $? -eq 0 ]]; then
cd "$(cat /tmp/nvim-cwd 2>/dev/null || echo .)"
fi
}
From the shell to neovim #
To go the other way, I just call fzf#run()
from the
fzf.vim plugin with cwd-history list
as
source and :tcd
as sink:3
function! ListWorkingDirs()
call fzf#run({
\ 'source': "cwd-history list",
\ 'sink': "tcd"
\})
endfunction
command! -nargs=0 ListWorkingDirs :call ListWorkingDirs()
nnoremap <leader>l :ListWorkingDirs<CR>
Conclusion #
And there you have it.
z
is 458 lines of zsh
code.
My re-implementation is 75 lignes of Python, 6 lines of zsh
, and 8 lines of
vimscript
.
It shares the database between the shell and the editor, it is never wrong, and the database stays clean and editable by hand.
Not bad I think :)
PS: You can also use z
directly with fzf
with a few lines of code, as show
in fzf wiki
-
Here since Python 3.2 ↩︎
-
It’s
cat
in reverse, do you get it? ↩︎ -
tcd
is a neovim-only feature. I already mentioned it in my post about vim, cwd, and neovim ↩︎
Thanks for reading this far :)
I'd love to hear what you have to say, so please feel free to leave a comment below, or read the contact page for more ways to get in touch with me.
Note that to get notified when new articles are published, you can either:
- Subscribe to the RSS feed
- Follow me on Mastodon
- Follow me on dev.to (mosts of my posts are mirrored there)
- Or send me an email to subscribe to my newsletter
Cheers!