exupero's blog

Neovim with Fennel

Years ago I switched from Vim to Neovim so I could use its API from Clojure. I built fair amount of functionality for the projects I was working on at the time, but when I changed jobs I changed to the team's preferred editor, and my custom extensions fell into disuse. When I went to revive them, I found the inter-process communication between Neovim and Clojure to be more complex than I wanted to manage, and looking around for alternatives I found Fennel and Aniseed.

One of Neovim's additions to Vim was to script it via Lua, and since Fennel is a lisp that compiles to Lua, it was natural for someone to write a plugin that integrates Fennel into Neovim. Enter Aniseed, which automatically compiles Fennel configuration code and evaluates it in Neovim. The author of Aniseed has also created Conjure for interactive evaluation of lisp code, and using it you can evaluate Fennel code live within Neovim and modify the running instance without reloading any config files. It's been a pleasure to use, much more so than my API-based approached. Also worthy of mention is nvim-local-fennel, for directory-specific Neovim configuration in Fennel.

Beyond utility functions and commands, I've added some manipulations of the quickfix list. Typically I populate it from ripgrep, whose output I parse into Neovim's expected structure:

(module quickfix
  {require {a aniseed.core
            nvim aniseed.nvim}})

(defn rg [q]
  (let [entries []
        str (vim.fn.system (.. "rg -nS '" q "'"))]
    (each [fname lnum text (str:gmatch "([^:\r\n]+):([0-9]+):([^\r\n]+)")]
      (table.insert entries {:filename fname
                             :module fname
                             :lnum (tonumber lnum)
                             :col (string.find text q)
                             :text text}))

(defn show! [title entries]
  (let [entries (a.vals entries)]
    (table.sort entries sort-by-file-and-line)
    (vim.fn.setqflist [] :r {:title title :items (a.vals entries)})

Manipulating the quickfix list is based on a generic filtering function, and either filters to or filters out entries based on matching modules or text:

(defn filter! [pred]
  (let [entries (vim.fn.getqflist)]
    (vim.fn.setqflist [] :r {:items (a.filter pred entries)})))

(defn qf-filter-modules [pattern]
  (filter! #(string.find $1.module pattern)))

(defn qf-filter-text [pattern]
  (filter! #(string.find $1.text pattern)))

(defn qf-remove-modules [pattern]
  (filter! #(not (string.find $1.module pattern))))

(defn qf-remove-text [pattern]
  (filter! #(not (string.find $1.text pattern))))

Not complex, but has proved useful.

More sophisticated has been a few helpers that leverage tree-sitter. For example, here's a function to show tree-sitter's abstract syntax tree for the highlighted code, useful for crafting tree-sitter queries:

(defn range-node []
  (let [[_ start-row start-col] (vim.fn.getpos "'<")
        [_ end-row end-col] (vim.fn.getpos "'>")]
    (-> (vim.treesitter.get_parser 0)
        (: :parse)
        (: :root)
        (: :descendant_for_range (a.dec start-row) start-col (a.dec end-row) end-col))))

(defn ast [_ _]
  (print (: (range-node) :sexpr)))

I've also walked tree-sitter ASTs to enter insert mode at the beginning or end of an argument list, as well as to swap arguments with their previous or next neighbors, similar to functionality provided in lisp code by vim-sexp (I recommend vim-sexp-mappings-for-regular-people).