# Interactive Neovim plugins
Writing Neovim plugins is easy:
- You create a buffer in a window or popup.
- You populate the buffer with text.
- You map keystrokes to specific actions that observe changes in the buffer and perform something.
Text interface allows different plugins to communicate without knowing anything about each other because text is a fundamental state and protocol for everything in Neovim.
You can use different editing plugins like plugin with treesitter objects with undotree without introducing a new way of editing to the protocol.
You can use oil.nvim with either block-selection or some multicursor plugin to do batch-renames without prior configuration because of uniform way to get and edit state.
In that sense oil is much more idiomatic than netrw.
But sometimes it's hard to completely rely on user editing to manipulate your plugin and when you do you need to make content of a buffer easy to parse. For that purpose oil requires file names to follow in the end of a line to prevent parsing ambiguity.
Plugins like neogit gave up on communicating with by editing text and introduced DOM-like structure for rendering and storing data. It may be a little orthogonal to the Neovim design but provides a clear and easy way to build complex and interactive interfaces.
Once I've tried building a plugin with a similar UI using Claude Code it failed miserably. Claude can identify that fugitive tracks info line by line and neogit introduces its own rendering engine but incapable to implement a similar and scalable solution.
Indeed there's little to no information on how to build complex plugins.
So this note is written to give some insight how to introduce structural meta for text in a very basic way.
## Structural rendering
To reason about regions of text rather than the whole buffer we introduce Node type:
1---@class NodeAttrs
2---@field hl string?
3---@field hl_all boolean
4
5---@class Node
6---@field children Node[]
7---@field data any
8---@field attrs NodeAttrs
9---@field value string
10local Node = {}
You may define helper methods on Node to make combining nodes easier.
Once you're done with building your tree you render it to a buffer and save a handle that contains description of buffer contents:
1---@class Markup
2---@field ns_id integer
3---@field ns_data table<integer, Region>
4---@field bufnr integer
5local Markup = {}
6
7---@class Region
8---@field start_row integer
9---@field start_col integer
10---@field end_row integer
11---@field end_col integer
12---@field data any
13---@field attrs NodeAttrs
14local Region = {}
15
16function Region:need_extmark()
17 if self.attrs.hl ~= nil then
18 return true
19 end
20
21 if self.data == nil then
22 return false
23 end
24
25 if #self.data ~= 0 then
26 return true
27 end
28
29 for _, _ in pairs(self.data) do
30 return true
31 end
32
33 return false
34end
35
36
37---@param bufnr integer
38---@return Markup
39function Node:render(bufnr, id)
40 local ns_id = vim.api.nvim_create_namespace(id)
41 local lines = {""}
42
43 vim.api.nvim_buf_clear_namespace(bufnr, ns_id, 0, -1)
44
45 vim.bo[bufnr].modifiable = true
46
47 local ns_data = {}
48
49 ---@type Region[]
50 local regions = {}
51
52 ---@param node Node
53 local function visit(node)
54 local start_row, start_col = #lines - 1, #lines[#lines]
55
56 if node:is_atom() then
57 for i = 1, #node.value do
58 local ch = node.value:sub(i, i)
59
60 if ch == "\n" then
61 table.insert(lines, "")
62 else
63 lines[#lines] = lines[#lines] .. ch
64 end
65 end
66 end
67
68 for _, child in ipairs(node.children) do
69 visit(child)
70 end
71
72 local end_row, end_col = #lines - 1, #lines[#lines]
73
74 local region = newRegion({
75 start_row = start_row,
76 start_col = start_col,
77 end_col = end_col,
78 end_row = end_row,
79 data = node.data,
80 attrs = node.attrs,
81 })
82
83 if region:need_extmark() then
84 table.insert(regions, region)
85 end
86 end
87
88 visit(self)
89
90 vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, lines)
91
92 vim.bo[bufnr].modifiable = false
93
94 for _, r in ipairs(util.reverse(regions)) do
95 local hl, hl_line = r.attrs.hl, nil
96
97 if r.attrs.hl_all then
98 hl, hl_line = hl_line, hl
99 end
100
101 local priority = 1
102
103 if hl_line == nil then
104 priority = 2
105 end
106
107 local ext_id = vim.api.nvim_buf_set_extmark(bufnr, ns_id, r.start_row, r.start_col, {
108 end_col = r.end_col,
109 end_row = r.end_row,
110 line_hl_group = hl_line,
111 hl_group = hl,
112 hl_mode ="replace",
113 priority = priority,
114 })
115
116 ns_data[ext_id] = r
117 end
118
119 return setmetatable({
120 ns_data = ns_data,
121 ns_id = ns_id,
122 bufnr = bufnr,
123 }, {
124 __index = Markup,
125 })
126end
127
Notice how we mark every region with extmark. It allows to color any element out of the box. But more importantly it allows to resolve a text region to a structural meta. Nodes can nest so we might want to sort metas by their depth.
1---@return Region[]
2function Markup:resolve(start_pos, end_pos)
3 local ret = vim.tbl_map(function(value)
4 return self.ns_data[value[1]]
5 end, vim.api.nvim_buf_get_extmarks(self.bufnr, self.ns_id, start_pos, end_pos, {overlap = true}))
6
7 table.sort(ret, function (a, b)
8 local dist_a = math.abs(start_pos[1] - a.start_row) +math.abs(start_pos[2] - a.start_col)
9 local dist_b = math.abs(start_pos[1] - b.start_row) +math.abs(start_pos[2] - b.start_col)
10
11 return dist_a < dist_b
12 end)
13
14 return ret
15end
Given a Markup object we can redraw the whole buffer on changes based on a state object that maps to root node without ever parsing buffer contents.
Imagine we are implementing another git plugin for neovim. We can now use structural meta to correctly identify what to stage:
1function StatusView:stage_at_cursor()
2 for _, region in ipairs(self:resolve(util.at2())) do
3 if region.data.type == MetaType.File then
4 self:stage({region.data.value.path})
5 return self:redraw()
6 end
7 end
8end
It's not like we should rebuild every plugin to full render -> resolve and update -> full render cycle. It's a last resort for complex plugins.
Extmarks do a great job following changes.
E.g.we can place expensive blame information in a buffer and update only on revision change since extmarks move on text editing. Redrawing whole buffer would be an overkill.
So it's generally better to follow oil approach for providing interactivity. And when it's not obvious nor possible to trust your text use extmarks to store your meta instead of parsing a buffer.