# Interactive Neovim plugins

Writing Neovim plugins is easy:

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.