Skip to content

Commit 44cbac6

Browse files
feat: convert latex asynchronously
## Details This change speeds up rendering `latex`, especially noticeable when there are many in the document. This is done by kicking off the jobs to do the conversion for all formulas in one batch, then waiting for them all to complete. This relies on the `vim.system` API so requires `0.10.0`, the old `vim.fn.system` based approach is kept for backwards compatibility, but will not have any performance improvements. In order to do this we need access to all the `latex` nodes at one point in time. This is done by storing them in the buffer level `Context` module. Then we take advantage of the new `last` parameter given to the handler to perform rendering for all the nodes. Before we reach `last` we simply append to the list and return an empty collection of marks. This will likely complicate debugging efforts in the future as computations are no longer neatly ordered by the node they are operating on, but I think this is a reasonable trade off. Once we start processing the nodes we create all the tasks with `vim.system`, then await the results of each, reducing the amount we are blocking as work can continue in the background. The results are stored in the existing `cache`, while rendering we get the cached value which should always be available. The `latex` nodes are retrieved as a list of lists, the initial level indicates that the following nodes all start on the same row. Currently this allows us to skip some duplicate work like calculating the `indent` level which will be the same for all `latex` nodes on the same row. In general this is a pretty insignificant gain, but still nice. In the future we can use this to combine virtual lines for multiple formulas to improve the overall rendering, currently the virtual lines just get stacked on top of each other. Semi-related changes: - update `log_runtime` to limit elapsed time to 10,000 instead of 1,000 - add `copy` method to `Line` module ## Results On my machine (M2 max) with about 100 formulas on screen to render looking at the runtime for the initial render. - default converter (`latex2text`): 1500ms -> 300ms (5x faster) - faster converter (`utftex`): 225ms -> 85ms (2.5x faster)
1 parent 63e0705 commit 44cbac6

File tree

11 files changed

+199
-82
lines changed

11 files changed

+199
-82
lines changed

doc/render-markdown.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
*render-markdown.txt* For NVIM v0.11.4 Last change: 2025 September 09
1+
*render-markdown.txt* For NVIM v0.11.4 Last change: 2025 September 10
22

33
==============================================================================
44
Table of Contents *render-markdown-table-of-contents*

lua/render-markdown/core/log.lua

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,9 @@ function M.runtime(name, callback)
5959
callback()
6060
local end_time = compat.uv.hrtime()
6161
local elapsed = (end_time - start_time) / 1e+6
62-
assert(elapsed < 1000, 'invalid elapsed time')
62+
assert(elapsed < 10000, 'invalid elapsed time')
6363
-- selene: allow(deprecated)
64-
vim.print(('%8s : %5.1f ms'):format(name:upper(), elapsed))
64+
vim.print(('%8s : %6.1f ms'):format(name:upper(), elapsed))
6565
end
6666
else
6767
return callback

lua/render-markdown/handler/latex.lua

Lines changed: 100 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ local str = require('render-markdown.lib.str')
88

99
---@class render.md.handler.buf.Latex
1010
---@field private context render.md.request.Context
11+
---@field private marks render.md.Marks
1112
---@field private config render.md.latex.Config
1213
local Handler = {}
1314
Handler.__index = Handler
@@ -21,100 +22,138 @@ Handler.cache = {}
2122
function Handler.new(buf)
2223
local self = setmetatable({}, Handler)
2324
self.context = Context.get(buf)
25+
self.marks = Marks.new(self.context, true)
2426
self.config = self.context.config.latex
2527
return self
2628
end
2729

2830
---@param root TSNode
31+
---@param last boolean
2932
---@return render.md.Mark[]
30-
function Handler:run(root)
33+
function Handler:run(root, last)
3134
if not self.config.enabled then
3235
return {}
3336
end
3437
if vim.fn.executable(self.config.converter) ~= 1 then
3538
log.add('debug', 'ConverterNotFound', self.config.converter)
3639
return {}
3740
end
38-
3941
local node = Node.new(self.context.buf, root)
4042
log.node('latex', node)
43+
self.context.latex:add(node)
44+
if last then
45+
local rows = self.context.latex:get()
46+
self:convert(rows)
47+
for _, nodes in ipairs(rows) do
48+
self:render(nodes)
49+
end
50+
end
51+
return self.marks:get()
52+
end
4153

42-
local marks = Marks.new(self.context, true)
43-
local output = str.split(self:convert(node.text), '\n', true)
44-
if self.config.virtual or #output > 1 then
45-
local col = node.start_col
46-
local _, first = node:line('first', 0)
47-
local prefix = str.pad(first and str.width(first:sub(1, col)) or col)
48-
local width = vim.fn.max(iter.list.map(output, str.width))
49-
50-
local text = {} ---@type string[]
51-
for _ = 1, self.config.top_pad do
52-
text[#text + 1] = ''
54+
---@private
55+
---@param rows render.md.Node[][]
56+
function Handler:convert(rows)
57+
local cmd = self.config.converter
58+
local inputs = {} ---@type string[]
59+
for _, nodes in ipairs(rows) do
60+
for _, node in ipairs(nodes) do
61+
local text = node.text
62+
local new = not Handler.cache[text]
63+
local unique = not vim.tbl_contains(inputs, text)
64+
if new and unique then
65+
inputs[#inputs + 1] = text
66+
end
5367
end
54-
for _, line in ipairs(output) do
55-
local suffix = str.pad(width - str.width(line))
56-
text[#text + 1] = prefix .. line .. suffix
68+
end
69+
if vim.system then
70+
local tasks = {} ---@type table<string, vim.SystemObj>
71+
for _, text in ipairs(inputs) do
72+
tasks[text] = vim.system({ cmd }, { stdin = text, text = true })
5773
end
58-
for _ = 1, self.config.bottom_pad do
59-
text[#text + 1] = ''
74+
for text, task in pairs(tasks) do
75+
local output = task:wait()
76+
local result = output.stdout
77+
if output.code ~= 0 or not result then
78+
log.add('error', 'ConverterFailed', cmd, result)
79+
result = 'error'
80+
end
81+
Handler.cache[text] = result
6082
end
61-
62-
local indent = self:indent(node.start_row, col)
63-
local lines = iter.list.map(text, function(part)
64-
local line = vim.list_extend({}, indent) ---@type render.md.mark.Line
65-
line[#line + 1] = { part, self.config.highlight }
66-
return line
67-
end)
68-
69-
local above = self.config.position == 'above'
70-
local row = above and node.start_row or node.end_row
71-
72-
marks:add(self.config, 'virtual_lines', row, 0, {
73-
virt_lines = lines,
74-
virt_lines_above = above,
75-
})
7683
else
77-
marks:over(self.config, true, node, {
78-
virt_text = { { output[1], self.config.highlight } },
79-
virt_text_pos = 'inline',
80-
conceal = '',
81-
})
84+
for _, text in ipairs(inputs) do
85+
local result = vim.fn.system(cmd, text)
86+
if vim.v.shell_error == 1 then
87+
log.add('error', 'ConverterFailed', cmd, result)
88+
result = 'error'
89+
end
90+
Handler.cache[text] = result
91+
end
8292
end
83-
return marks:get()
8493
end
8594

8695
---@private
87-
---@param text string
88-
---@return string
89-
function Handler:convert(text)
90-
local result = Handler.cache[text]
91-
if not result then
92-
local converter = self.config.converter
93-
result = vim.fn.system(converter, text)
94-
if vim.v.shell_error == 1 then
95-
log.add('error', 'ConverterFailed', converter, result)
96-
result = 'error'
96+
---@param nodes render.md.Node[]
97+
function Handler:render(nodes)
98+
local first = nodes[1]
99+
local indent = self:indent(first)
100+
local _, line = first:line('first', 0)
101+
102+
for _, node in ipairs(nodes) do
103+
local output = str.split(Handler.cache[node.text], '\n', true)
104+
if self.config.virtual or #output > 1 then
105+
local col = node.start_col
106+
local prefix = str.pad(line and str.width(line:sub(1, col)) or col)
107+
local width = vim.fn.max(iter.list.map(output, str.width))
108+
109+
local texts = {} ---@type string[]
110+
for _ = 1, self.config.top_pad do
111+
texts[#texts + 1] = ''
112+
end
113+
for _, text in ipairs(output) do
114+
local suffix = str.pad(width - str.width(text))
115+
texts[#texts + 1] = prefix .. text .. suffix
116+
end
117+
for _ = 1, self.config.bottom_pad do
118+
texts[#texts + 1] = ''
119+
end
120+
121+
local lines = iter.list.map(texts, function(text)
122+
return indent:copy():text(text, self.config.highlight):get()
123+
end)
124+
125+
local above = self.config.position == 'above'
126+
local row = above and node.start_row or node.end_row
127+
128+
self.marks:add(self.config, 'virtual_lines', row, 0, {
129+
virt_lines = lines,
130+
virt_lines_above = above,
131+
})
132+
else
133+
self.marks:over(self.config, true, node, {
134+
virt_text = { { output[1], self.config.highlight } },
135+
virt_text_pos = 'inline',
136+
conceal = '',
137+
})
97138
end
98-
Handler.cache[text] = result
99139
end
100-
return result
101140
end
102141

103142
---@private
104-
---@param row integer
105-
---@param col integer
106-
---@return render.md.mark.Line
107-
function Handler:indent(row, col)
143+
---@param node render.md.Node
144+
---@return render.md.Line
145+
function Handler:indent(node)
108146
local buf = self.context.buf
109-
local node = vim.treesitter.get_node({
147+
local markdown = vim.treesitter.get_node({
110148
bufnr = buf,
111-
pos = { row, col },
149+
pos = { node.start_row, node.start_col },
112150
lang = 'markdown',
113151
})
114-
if not node then
115-
return {}
152+
if not markdown then
153+
return self.context.config:line()
154+
else
155+
return Indent.new(self.context, Node.new(buf, markdown)):line(true)
116156
end
117-
return Indent.new(self.context, Node.new(buf, node)):line(true):get()
118157
end
119158

120159
---@class render.md.handler.Latex: render.md.Handler
@@ -123,7 +162,7 @@ local M = {}
123162
---@param ctx render.md.handler.Context
124163
---@return render.md.Mark[]
125164
function M.parse(ctx)
126-
return Handler.new(ctx.buf):run(ctx.root)
165+
return Handler.new(ctx.buf):run(ctx.root, ctx.last)
127166
end
128167

129168
return M

lua/render-markdown/health.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ local state = require('render-markdown.state')
55
local M = {}
66

77
---@private
8-
M.version = '8.8.2'
8+
M.version = '8.8.3'
99

1010
function M.check()
1111
M.start('versions')

lua/render-markdown/lib/line.lua

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ function Line:extend(other)
3737
return self
3838
end
3939

40+
---@return render.md.Line
41+
function Line:copy()
42+
local result = setmetatable({}, Line)
43+
result.highlight = self.highlight
44+
result.line = vim.list_extend({}, self.line)
45+
return result
46+
end
47+
4048
---@param s string
4149
---@param highlight? render.md.mark.Hl
4250
---@return render.md.Line

lua/render-markdown/request/context.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
local Callout = require('render-markdown.request.callout')
22
local Checkbox = require('render-markdown.request.checkbox')
33
local Conceal = require('render-markdown.request.conceal')
4+
local Latex = require('render-markdown.request.latex')
45
local Offset = require('render-markdown.request.offset')
56
local Used = require('render-markdown.request.used')
67
local View = require('render-markdown.request.view')
@@ -14,6 +15,7 @@ local str = require('render-markdown.lib.str')
1415
---@field conceal render.md.request.Conceal
1516
---@field callout render.md.request.Callout
1617
---@field checkbox render.md.request.Checkbox
18+
---@field latex render.md.request.Latex
1719
---@field offset render.md.request.Offset
1820
---@field used render.md.request.Used
1921
local Context = {}
@@ -33,6 +35,7 @@ function Context.new(buf, win, config, view)
3335
self.conceal = Conceal.new(buf, win, view)
3436
self.callout = Callout.new()
3537
self.checkbox = Checkbox.new()
38+
self.latex = Latex.new()
3639
self.offset = Offset.new()
3740
self.used = Used.new()
3841
return self
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---@class render.md.request.Latex
2+
---@field private nodes render.md.Node[]
3+
local Latex = {}
4+
Latex.__index = Latex
5+
6+
---@return render.md.request.Latex
7+
function Latex.new()
8+
local self = setmetatable({}, Latex)
9+
self.nodes = {}
10+
return self
11+
end
12+
13+
---@param node render.md.Node
14+
function Latex:add(node)
15+
self.nodes[#self.nodes + 1] = node
16+
end
17+
18+
---@return render.md.Node[][]
19+
function Latex:get()
20+
table.sort(self.nodes)
21+
local result = {} ---@type render.md.Node[][]
22+
result[#result + 1] = { self.nodes[1] }
23+
for i = 2, #self.nodes do
24+
local node, last = self.nodes[i], result[#result]
25+
if node.start_row == last[#last].start_row then
26+
last[#last + 1] = node
27+
else
28+
result[#result + 1] = { node }
29+
end
30+
end
31+
return result
32+
end
33+
34+
return Latex

neovim.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,8 @@ globals:
3838
- type: bool
3939
- type: string
4040
required: false
41+
assert.not_nil:
42+
args:
43+
- type: any
44+
- type: string
45+
required: false

tests/helpers/system.lua

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---@module 'luassert'
2+
3+
local stub = require('luassert.stub')
4+
5+
---@class render.md.test.Task: vim.SystemObj
6+
---@field private stdout string
7+
local Task = {}
8+
Task.__index = Task
9+
10+
---@param stdout string
11+
---@return render.md.test.Task
12+
function Task.new(stdout)
13+
local self = setmetatable({}, Task)
14+
self.stdout = stdout
15+
return self
16+
end
17+
18+
---@return vim.SystemCompleted
19+
function Task:wait()
20+
---@type vim.SystemCompleted
21+
return { code = 0, signal = 0, stdout = self.stdout }
22+
end
23+
24+
---@class render.md.test.System
25+
local M = {}
26+
27+
---@param command string
28+
---@param outputs table<string, string>
29+
function M.mock(command, outputs)
30+
stub.new(vim.fn, 'executable', function(expr)
31+
assert.same(command, expr)
32+
return 1
33+
end)
34+
stub.new(vim, 'system', function(cmd, opts)
35+
assert.same({ command }, cmd)
36+
local output = outputs[opts.stdin]
37+
assert.not_nil(output, ('missing output: %s'):format(opts.stdin))
38+
return Task.new(output)
39+
end)
40+
end
41+
42+
return M

0 commit comments

Comments
 (0)