Skip to content

Commit c7a2055

Browse files
feat: Massive performance improvement, only parse / render visible range
## Details This is a pretty large overhaul to how events are handled by this plugin. Instead of parsing & rendering the entire buffer only render the lines within the neovim viewport + small buffer. This involves putting ranges on the iter_captures calls as well as when parsing the language trees. All this logic is centralized in the Context class. Add a debounce on the updates that defaults to 100ms, users can modify this if they prefer more frequent udpates, this would impact overall neovim performance. The performance is now to the point where doing the Obsidian like behavior of rendering all lines in insert mode except the line being modified works really well, I may make this the default experience in a future update. This does involve adding all the *I events to our autocommand, I have made this based on the render_modes value to avoid listening to this events if it is not necessary. We do parse the ranges more frequently since cursor movements could put the marks out of range so it's not a complete win in all cases, worst case being small files that can be contained in the viewport. Still these are typically so fast anyway that doing it more times is hard to notice.
1 parent 8156620 commit c7a2055

17 files changed

+186
-217
lines changed

Diff for: README.md

+5-2
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ require('render-markdown').setup({
125125
-- Maximum file size (in MB) that this plugin will attempt to render
126126
-- Any file larger than this will effectively be ignored
127127
max_file_size = 1.5,
128+
-- Milliseconds that must pass before updating marks, updates occur
129+
-- within the context of the visible window, not the entire buffer
130+
debounce = 100,
128131
-- Capture groups that get pulled from markdown
129132
markdown_query = [[
130133
(atx_heading [
@@ -427,8 +430,8 @@ require('render-markdown').setup({
427430
-- More granular configuration mechanism, allows different aspects of buffers
428431
-- to have their own behavior. Values default to the top level configuration
429432
-- if no override is provided. Supports the following fields:
430-
-- enabled, max_file_size, render_modes, anti_conceal, heading, code, dash, bullet,
431-
-- checkbox, quote, pipe_table, callout, link, sign, win_options
433+
-- enabled, max_file_size, debounce, render_modes, anti_conceal, heading, code,
434+
-- dash, bullet, checkbox, quote, pipe_table, callout, link, sign, win_options
432435
overrides = {
433436
-- Overrides for different buftypes, see :h 'buftype'
434437
buftype = {

Diff for: benches/medium_spec.lua

+4-4
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ local util = require('benches.util')
44

55
describe('medium.md', function()
66
it('default', function()
7-
local base_marks = 2998
8-
util.between(25, 75, util.setup('temp/medium.md'))
7+
local base_marks = 46
8+
util.between(20, 30, util.setup('temp/medium.md'))
99
util.num_marks(base_marks)
1010

11-
util.between(0, 1, util.move_down(3))
11+
util.between(0, 5, util.move_down(3))
1212
util.num_marks(base_marks + 2)
1313

14-
util.between(25, 50, util.insert_mode())
14+
util.between(1, 15, util.insert_mode())
1515
util.num_marks(base_marks + 2)
1616
end)
1717
end)

Diff for: benches/medium_table_spec.lua

+4-4
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ local util = require('benches.util')
44

55
describe('medium-table.md', function()
66
it('default', function()
7-
local base_marks = 30698
8-
util.between(450, 600, util.setup('temp/medium-table.md'))
7+
local base_marks = 468
8+
util.between(80, 105, util.setup('temp/medium-table.md'))
99
util.num_marks(base_marks)
1010

11-
util.between(1, 5, util.move_down(1))
11+
util.between(1, 20, util.move_down(1))
1212
util.num_marks(base_marks + 2)
1313

14-
util.between(400, 550, util.insert_mode())
14+
util.between(5, 30, util.insert_mode())
1515
util.num_marks(base_marks + 2)
1616
end)
1717
end)

Diff for: benches/readme_spec.lua

+4-4
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ local util = require('benches.util')
44

55
describe('README.md', function()
66
it('default', function()
7-
local base_marks = 441
8-
util.between(25, 75, util.setup('README.md'))
7+
local base_marks = 55
8+
util.between(30, 50, util.setup('README.md'))
99
util.num_marks(base_marks)
1010

11-
util.between(0, 1, util.move_down(1))
11+
util.between(1, 5, util.move_down(1))
1212
util.num_marks(base_marks + 2)
1313

14-
util.between(20, 40, util.insert_mode())
14+
util.between(10, 20, util.insert_mode())
1515
util.num_marks(base_marks + 2)
1616
end)
1717
end)

Diff for: benches/util.lua

+3-6
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,11 @@ local truthy = assert.truthy
88
local M = {}
99

1010
---@param file string
11-
---@param opts? render.md.UserConfig
1211
---@return number
13-
function M.setup(file, opts)
12+
function M.setup(file)
1413
return M.time(function()
15-
require('render-markdown').setup(opts)
14+
require('render-markdown').setup({ debounce = 0 })
1615
vim.cmd('e ' .. file)
17-
vim.wait(0)
1816
end)
1917
end
2018

@@ -25,15 +23,13 @@ function M.move_down(n)
2523
M.feed(string.format('%dj', n))
2624
-- Unsure why, but the CursorMoved event needs to be triggered manually
2725
vim.api.nvim_exec_autocmds('CursorMoved', {})
28-
vim.wait(0)
2926
end)
3027
end
3128

3229
---@return number
3330
function M.insert_mode()
3431
return M.time(function()
3532
M.feed('i')
36-
vim.wait(0)
3733
end)
3834
end
3935

@@ -43,6 +39,7 @@ end
4339
function M.time(f)
4440
local start = vim.uv.hrtime()
4541
f()
42+
vim.wait(0)
4643
return (vim.uv.hrtime() - start) / 1e+6
4744
end
4845

Diff for: doc/render-markdown.txt

+5-2
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,9 @@ Full Default Configuration ~
158158
-- Maximum file size (in MB) that this plugin will attempt to render
159159
-- Any file larger than this will effectively be ignored
160160
max_file_size = 1.5,
161+
-- Milliseconds that must pass before updating marks, updates occur
162+
-- within the context of the visible window, not the entire buffer
163+
debounce = 100,
161164
-- Capture groups that get pulled from markdown
162165
markdown_query = [[
163166
(atx_heading [
@@ -460,8 +463,8 @@ Full Default Configuration ~
460463
-- More granular configuration mechanism, allows different aspects of buffers
461464
-- to have their own behavior. Values default to the top level configuration
462465
-- if no override is provided. Supports the following fields:
463-
-- enabled, max_file_size, render_modes, anti_conceal, heading, code, dash, bullet,
464-
-- checkbox, quote, pipe_table, callout, link, sign, win_options
466+
-- enabled, max_file_size, debounce, render_modes, anti_conceal, heading, code,
467+
-- dash, bullet, checkbox, quote, pipe_table, callout, link, sign, win_options
465468
overrides = {
466469
-- Overrides for different buftypes, see :h 'buftype'
467470
buftype = {

Diff for: lua/render-markdown/buffer_state.lua

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---@class render.md.BufferState
2+
---@field private buf integer
3+
---@field private timer uv_timer_t
4+
---@field private running boolean
5+
---@field state? 'default'|'rendered'
6+
local BufferState = {}
7+
BufferState.__index = BufferState
8+
9+
---@param buf integer
10+
---@return render.md.BufferState
11+
function BufferState.new(buf)
12+
local self = setmetatable({}, BufferState)
13+
self.buf = buf
14+
self.timer = (vim.uv or vim.loop).new_timer()
15+
self.running = false
16+
self.state = nil
17+
return self
18+
end
19+
20+
---@param ms integer
21+
---@param cb fun(buf: integer)
22+
function BufferState:debounce(ms, cb)
23+
self.timer:start(ms, 0, function()
24+
self.running = false
25+
end)
26+
if not self.running then
27+
self.running = true
28+
vim.schedule_wrap(cb)(self.buf)
29+
end
30+
end
31+
32+
return BufferState

Diff for: lua/render-markdown/context.lua

+37-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
---@class render.md.Context
22
---@field private buf integer
33
---@field private win integer
4+
---@field private top integer
5+
---@field private bottom integer
46
---@field private conceallevel integer
57
---@field private conceal? table<integer, [integer, integer][]>
68
---@field private links table<integer, [integer, integer, string][]>
@@ -9,10 +11,15 @@ Context.__index = Context
911

1012
---@param buf integer
1113
---@param win integer
12-
function Context.new(buf, win)
14+
---@param offset integer
15+
function Context.new(buf, win, offset)
1316
local self = setmetatable({}, Context)
1417
self.buf = buf
1518
self.win = win
19+
local top = vim.api.nvim_win_call(win, vim.fn.winsaveview).topline - 1
20+
local height = vim.api.nvim_win_get_height(win)
21+
self.top = math.max(top - offset, 0)
22+
self.bottom = top + height + offset
1623
self.conceallevel = vim.api.nvim_get_option_value('conceallevel', { scope = 'local', win = win })
1724
self.conceal = nil
1825
self.links = {}
@@ -40,6 +47,28 @@ function Context:get_width()
4047
return vim.api.nvim_win_get_width(self.win)
4148
end
4249

50+
---@return Range2
51+
function Context:range()
52+
return { self.top, self.bottom }
53+
end
54+
55+
---@param node TSNode
56+
---@return boolean
57+
function Context:contains_node(node)
58+
local top, _, bottom, _ = node:range()
59+
return top <= self.bottom and bottom >= self.top
60+
end
61+
62+
---@param root TSNode
63+
---@param query vim.treesitter.Query
64+
---@param cb fun(capture: string, node: TSNode, metadata: vim.treesitter.query.TSMetadata)
65+
function Context:query(root, query, cb)
66+
for id, node, metadata in query:iter_captures(root, self.buf, self.top, self.bottom) do
67+
local capture = query.captures[id]
68+
cb(capture, node, metadata)
69+
end
70+
end
71+
4372
---@param row integer
4473
---@return [integer, integer][]
4574
function Context:get_conceal(row)
@@ -58,6 +87,7 @@ function Context:compute_conceal()
5887
end
5988
local ranges = {}
6089
local parser = vim.treesitter.get_parser(self.buf)
90+
parser:parse(self:range())
6191
parser:for_each_tree(function(tree, language_tree)
6292
local nodes = self:compute_conceal_nodes(language_tree:lang(), tree:root())
6393
for _, node in ipairs(nodes) do
@@ -76,6 +106,9 @@ end
76106
---@param root TSNode
77107
---@return TSNode[]
78108
function Context:compute_conceal_nodes(language, root)
109+
if not self:contains_node(root) then
110+
return {}
111+
end
79112
if not vim.tbl_contains({ 'markdown', 'markdown_inline' }, language) then
80113
return {}
81114
end
@@ -84,11 +117,11 @@ function Context:compute_conceal_nodes(language, root)
84117
return {}
85118
end
86119
local nodes = {}
87-
for _, node, metadata in query:iter_captures(root, self.buf) do
120+
self:query(root, query, function(_, node, metadata)
88121
if metadata.conceal ~= nil then
89122
table.insert(nodes, node)
90123
end
91-
end
124+
end)
92125
return nodes
93126
end
94127

@@ -101,7 +134,7 @@ local M = {}
101134
---@param buf integer
102135
---@param win integer
103136
function M.reset(buf, win)
104-
cache[buf] = Context.new(buf, win)
137+
cache[buf] = Context.new(buf, win, 10)
105138
end
106139

107140
---@param buf integer

Diff for: lua/render-markdown/extmark.lua

-75
This file was deleted.

Diff for: lua/render-markdown/handler/markdown.lua

+4-8
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,7 @@ local M = {}
2020
function M.parse(root, buf)
2121
local config = state.get_config(buf)
2222
local marks = {}
23-
local query = state.markdown_query
24-
for id, node in query:iter_captures(root, buf) do
25-
local capture = query.captures[id]
23+
context.get(buf):query(root, state.markdown_query, function(capture, node)
2624
local info = ts.info(node, buf)
2725
logger.debug_node_info(capture, info)
2826
if capture == 'heading' then
@@ -38,23 +36,21 @@ function M.parse(root, buf)
3836
elseif capture == 'checkbox_checked' then
3937
list.add_mark(marks, M.checkbox(config, info, config.checkbox.checked))
4038
elseif capture == 'quote' then
41-
local quote_query = state.markdown_quote_query
42-
for nested_id, nested_node in quote_query:iter_captures(info.node, buf) do
43-
local nested_capture = quote_query.captures[nested_id]
39+
context.get(buf):query(info.node, state.markdown_quote_query, function(nested_capture, nested_node)
4440
local nested_info = ts.info(nested_node, buf)
4541
logger.debug_node_info(nested_capture, nested_info)
4642
if nested_capture == 'quote_marker' then
4743
list.add_mark(marks, M.quote_marker(config, nested_info, info))
4844
else
4945
logger.unhandled_capture('markdown quote', nested_capture)
5046
end
51-
end
47+
end)
5248
elseif capture == 'table' then
5349
vim.list_extend(marks, M.pipe_table(config, buf, info))
5450
else
5551
logger.unhandled_capture('markdown', capture)
5652
end
57-
end
53+
end)
5854
return marks
5955
end
6056

0 commit comments

Comments
 (0)