Navigating Markdown Headings In Neovim

Neovim provides many ways to navigate within documents, as well as a variety of tools that users can use to implement custom capabilities that are unique to their workflows.

This post demonstrates a few ways that these can be combined to develop keymaps that allow users to quickly jump between Markdown headings in a document.

Implementation #1 - Patterns

The first implementation creates a simple keymap that leverages patterns to identify markdown headings, making it quick and easy to jump between markdown headings.

Create a markdown.lua file in the ftplugin directory if it doesn't already exist. Your configuration directory should look something like this:

~/.config/nvim/ ($XDG_CONFIG_HOME)
|
+- ftplugin/
| - markdown.lua
|
+- lua/
+- plugin/
+- init.lua

Now, add the following keymaps to that file:

vim.keymap.set("n", "<A-j>", "/^#\\+ <CR>")

vim.keymap.set("n", "<A-k>", "?^#\\+ <CR>")

These keymaps take advantage of the search operators / and ? to search forward and backward from the current cursor position, respectively. In each case, Neovim searches for the pattern ^#\\+, which matches any line that starts with one or more # characters followed by a space. As a bonus, this keymap also supports counts, allowing you to navigate even faster by jumping multiple headings at a time, which can be a big productivity boost when navigating long documents.

As a quick demo, let's take the following Markdown file. Starting from the top, in steps 1 and 2 we hit <A-j> to jump forward to each heading, then finally in step 3 we use <A-k> to jump back up to the previous heading:

Before
#·Heading·1
 
Text
 
##·Heading·2
 
Text
 
###·Heading·3
NORMAL
Top
1:1
 
Jump to Next Heading
<A-j>
#·Heading·1
 
Text
 
##·Heading·2
 
Text
 
###·Heading·3
NORMAL
50%
5:1
 
Jump to Next Heading (Again)
<A-j>
#·Heading·1
 
Text
 
##·Heading·2
 
Text
 
###·Heading·3
NORMAL
90%
9:1
 
Jump to Previous Heading
<A-k>
#·Heading·1
 
Text
 
##·Heading·2
 
Text
 
###·Heading·3
NORMAL
50%
5:1
 

This keymap is very simple to implement and works quite well, but its use of a fairly naive pattern makes it potentially susceptible to false matches.

Implementation #2 - Treesitter

Let's now take a look at another implementation that uses Treesitter to more accurately target markdown headings.

Replace the contents of the ftplugin/markdown.lua file we created earlier with the following:

local ts_utils = require("nvim-treesitter.ts_utils")

local M = {
-- define the query
query = vim.treesitter.query.parse("markdown", "((atx_heading) @header)"),
}

M.init = function()
-- search the current buffer
M.buffer = 0

-- references to lines within the buffer
M.first_line = 0
M.current_line = vim.fn.line(".")
M.previous_line = M.current_line - 1
M.next_line = M.current_line + 1
M.last_line = -1

-- default count
M.count = 1

if vim.v.count > 1 then
M.count = vim.v.count
end

-- list of captures
M.captures = {}

-- get the parser
M.parser = vim.treesitter.get_parser()
-- parse the tree
M.tree = M.parser:parse()[1]
-- get the root of the resulting tree
M.root = M.tree:root()
end

M.next_heading = function()
M.init()

-- populate captures with all matching nodes from the next line to
-- the last line of the buffer
for _, node, _, _ in
M.query:iter_captures(M.root, M.buffer, M.next_line, M.last_line)
do
table.insert(M.captures, node)
end

-- get the node at the specified index
ts_utils.goto_node(M.captures[M.count])
end

M.previous_heading = function()
M.init()

-- if we are already at the top of the buffer
-- there are no previous headings
if M.current_line == M.first_line + 1 then
return
end

-- populate captures with all matching nodes from the first line
-- of the buffer to the previous line
for _, node, _, _ in
M.query:iter_captures(M.root, M.buffer, M.first_line, M.previous_line)
do
table.insert(M.captures, node)
end

-- get the node at the specified index
ts_utils.goto_node(M.captures[#M.captures - M.count + 1])
end

-- define the keymaps
vim.keymap.set("n", "<A-j>", M.next_heading)

vim.keymap.set("n", "<A-k>", M.previous_heading)

This is a bit more complicated, but still fairly simple. We start by creating a Lua module, followed by an initialization function that collects information about the buffer, cursor, and count. Finally, we create several functions that to implement jumping in the forward and reverse directions, respectively. Finally, we create the keymaps that call the functions we just created.

Now, let's repeat the demo using our new keymaps. Starting from the top, in steps 1 and 2 we hit <A-j> to step forward through the headings, then finally in step 3 we use <A-k> to jump back up to the previous heading:

Before
#·Heading·1
 
Text
 
##·Heading·2
 
Text
 
###·Heading·3
NORMAL
Top
1:1
 
Jump to Next Heading
<A-j>
#·Heading·1
 
Text
 
##·Heading·2
 
Text
 
###·Heading·3
NORMAL
50%
5:1
 
Jump to Next Heading (Again)
<A-j>
#·Heading·1
 
Text
 
##·Heading·2
 
Text
 
###·Heading·3
NORMAL
90%
9:1
 
Jump to Previous Heading
<A-k>
#·Heading·1
 
Text
 
##·Heading·2
 
Text
 
###·Heading·3
NORMAL
50%
5:1
 

Each of the implementations here demonstrate the simplicity with which functionality can be added to Neovim. Better yet, this functionality can be added without having to rely on 3rd-party plugins.