💾 Archived View for dmerej.info › en › blog › 0041-rewriting-z-from-scratch.gmi captured on 2024-08-31 at 12:06:48. Gemini links have been rewritten to link to archived content
⬅️ Previous capture (2022-07-16)
-=-=-=-=-=-=-
2017, May 21 - Dimitri Merejkowsky License: CC By 4.0
z[1] 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.
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.
`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.
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:
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:
The code is on github[2] if you want to take a look.
2: https://github.com/dmerejkowsky/dotfiles/blob/master/bin/cwd-history
Some notes:
def get_db_path(): zsh_share_path = os.path.expanduser("~/.local/share/zsh") os.makedirs(zsh_share_path, exist_ok=True)
def add_to_db(path): path = os.path.realpath(path)
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")
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 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
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[3]. 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:
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.
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 }
To go the other way, I just call `fzf#run()` from the fzf.vim[4] plugin with `cwd-history list` as source and `:tcd` as sink:
4: https://github.com/junegunn/fzf.vim
function! ListWorkingDirs() call fzf#run({ \ 'source': "cwd-history list", \ 'sink': "tcd" \}) endfunction command! -nargs=0 ListWorkingDirs :call ListWorkingDirs() nnoremap <leader>l :ListWorkingDirs<CR>
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
----