Erik's Blog - User-Defined Completion in Vim for a Zettelkasten - 2024-08-06

The Goal

For a while now I have been building a digital Zettelkasten for my knowledge management. The concept of a chaotic pile of random notes, i.e., Markdown files, that are connected through hyperlinks fits both my aesthetic and my way of thinking. Luhman’s original slip box was probably everything but chaotic in his mind, but I would like to think that this was simply out of necessity, it being a physical box with collections of catalogued and numbered physical paper slips in it.

A lot of people following this methodology use Obsidian nowadays, which is a lovely piece of software. You can have it on multiple devices, sync it automatically between them, and, of course, the connection graph visualization is just very, very pretty. I can understand why people use it. I played around with it myself, as well, but after I realized that I don’t actually take or refer to notes on the go when I don’t have my laptop with me, I went back to my already working setup. While the experience of using Vim on mobile may be questionable, it is unmatched on a platform that allows touch typing with ten fingers.

Now, a quick primer on what my zettelkasten looks like; each note is a separate Markdown file with a time stamp and a title, e.g., 20240805121500_hello_world.md which might link to 19780222143000_the_c_programming_language.md. Starting the filename with a time stamp makes it both unique (for all intents and purposes) and gives a chronological view of when notes were created; the latter is, admittedly, unlikely to be practically useful, but nonetheless it gives you a warm feeling when you can link a new idea with a note from three years ago.

All of this works very nicely with plain Vim and a single directory (called zettelkasten) which I have under source control. Normal mode command gf lets you navigate to the file under the cursor, gx opens external links, we can search for files and even grep through their contents.

There is just one thing missing: easy insert mode insertion of links to other markdown files. Obsidian does this very nicely, fuzzy matching the titles of existing notes and suggesting it while the user is typing. The best thing in vanilla Vim that I could think of was <c-x><x-f>, i.e., filename completion. This works, but due to the nature of my filenames, you have to start with the time stamp, which I obviously don’t know.

Initial Workflow

Telescope allows us to fuzzy search filenames in the current directory (and its subdirectories, but that’s not relevant here). You could, of course, also just use something like :find *title*, which is not as fuzzy by default, but should work fine for the normal use cases. Then we remember the time stamp of the file we have found and use file name completion <c-x><c-f>.

This works well, but it is a bit inconvenient. And nothing irks a programmer more than something slightly inconvenient. Especially when you’re supposed to be working on something else.

A First Attempt

As is often the case when you don’t know what you’re doing, I ended up reinventing the wheel by assembling parts of the working setup in a different way. We already have our file selection working with Telescope; and Telescope is wonderfully extensible, so why don’t we just define a custom action and use that in an insert mode user command? We can even assign it a key map like <c-x><c-j> so it feels like a Vim native completion command.

local actions = require "telescope.actions"
local action_state = require "telescope.actions.state"
local function run_selection(prompt_bufnr)
  actions.select_default:replace(function()
    actions.close(prompt_bufnr)
    local selection = action_state.get_selected_entry()
    local cur = vim.api.nvim_win_get_cursor(0)
    -- cursor position is (1,0) based
    vim.api.nvim_buf_set_text(0, cur[1] - 1, cur[2], cur[1] - 1, cur[2], { selection[1] })
    vim.api.nvim_win_set_cursor(0, { cur[1], cur[2] + #selection[1] })
  end)
  return true
end

local fuzzy_file = function()
  local opts = {
    attach_mappings = run_selection
  }
  require('telescope.builtin').find_files(opts)
end

vim.api.nvim_buf_set_keymap(0, 'i', '<c-x><c-j>', '', { callback = fuzzy_file })

This… works; it calls up the usual Telescope search pane and it inserts the selected file’s name. But when you really start to use it, it is a little messy.

First of all, it leaves insert mode, which is just not what you expect when you use a completion command. How could we address this? Let’s put a <c-o> in front of the function call.

vim.api.nvim_buf_create_user_command(0, 'FuzzyFile', fuzzy_file, {})
vim.api.nvim_buf_set_keymap(0, 'i', '<c-x><c-j>', '<c-o>FuzzyFile<cr>', {})

Because Telescope starts off in insert mode in a new buffer this just leads to a capital A being inserted into the search pane. Without having looked into it, my expectation is that Vim translates <c-o>... into something like ...A expecting to be in normal mode.

Kids, don’t misuse features in a context for which they were not designed!

Secondly, the user experience is different from normal completion. It’s always the small things that take you out of your flow. How could we improve this?

User-Defined Completion to the Rescue

Vim comes with a variety of different built-in completion options; you can complete by filename <c-x><c-f>, tag <c-x><c-]>, even based on the thesaurus <c-x><c-t>, just to name a few. One of my personal favorites is line completion <c-x><c-l>; try it sometime, it’s great, especially when writing tests or error handling in Go.

On top of all of these it also offers the possibility for user-defined completion by assigning a Vimscript function to the completefunc option (:help 'completefunc'), which is then triggered with <c-x><c-u>.

fun! CompleteFileNames(findstart, base)
  if a:findstart
    " locate the start of the word
    let line = getline('.')
    let start = col('.') - 1
    while start > 0 && line[start - 1] =~ '\a'
      let start -= 1
    endwhile
    return start
  else
    let res = []
    if a:base == ""
        let res = systemlist("ls")
    else
        let res = systemlist("ls | rg --ignore-case " .. a:base)
    endif
    return res
  endif
endfun

setlocal completefunc=CompleteFileNames

The function in completefunc is called twice during completion, once to identify the base that is to be completed (findstart == 1) and then again with the base that was identified. The identification logic will be pretty standard in most completion implementations; the actual logic happens in the else branch.

If the base is empty, e.g., because we just started completion at the start of a line or after a space, we just return the result of ls (to offer something); otherwise we parse the output of ls through ripgrep looking for the base of our completion.

We could make the completion even a little fuzzy by inserting something like .* in between each character of base, but this should do for now.

If you are an experienced Vim user, this might be what you have been screaming about since you starting reading this post. This piece of code is now in a file type plugin for Markdown files (markdown.vim).

I tried getting this to work in pure Lua, but unfortunately vim.bo.completefunc does not seem to accept a Lua function. We could do some tricks with Lua-Vimscript interoperability, but if we have to use Vimscript anyway, might as well keep it as is; the function is small enough.

Final Thoughts

Making things yourself seems to be becoming a recurring theme on this blog. So, what did we learn today? Implementing a custom Telescope action and some Vimscript; a way to implement our own completion algorithm in our editor; we have a setup just the way we want it; and, maybe most importantly, we had some fun, didn’t we?