Skip to content

Commit 7f75b16

Browse files
feat: handle arbitrary block quote nesting nicely
## Details Request: #404 While the request seems to be about using the parent color across the board, I've instead implemented this in a slightly more complex but hopefully more pretty way. Rather than highlighting entire sets of `block quote` markers, i.e. `> > >`, with a single highlight we now associate colors based on the level of the block quote. What this means is if we're into a triple nested block quote where: - Level 1: `[!IMPORTANT]` callout - Level 2: no callout - Level 3: `[!BUG]` callout We'll use highlight as follows: - `>` 1: `[!IMPORTANT]` callout highlight - `>` 2: default `quote` highlight - `>` 3: `[!BUG]` callout highlight This applies throughout the `block quote` on all lines. To handle the `lazy` provision of the `markdown` spec we simply go front to back one level at a time. So if on some line `> >` is added even though the level is still technically 3, we only highlight the first 2 according to the first 2 levels. So as not to limit the usage to callouts `quote.icon` and `quote.highlight` now both support taking a list of strings in addition to a single string value. When a list is provided it is indexed into using a `cycle` access pattern based on the nesting of the current `block quote`. This allows users to change both the color and the icon used based on how deeply nested the block quote is. Add unit tests for updated quote logic since it's not trivial.
1 parent dfffdd2 commit 7f75b16

File tree

12 files changed

+197
-109
lines changed

12 files changed

+197
-109
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 0.11.0 Last change: 2025 April 21
1+
*render-markdown.txt* For 0.11.0 Last change: 2025 April 22
22

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

lua/render-markdown/config/quote.lua

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
---@class (exact) render.md.quote.Config: render.md.base.Config
2-
---@field icon string
2+
---@field icon string|string[]
33
---@field repeat_linebreak boolean
4-
---@field highlight string
4+
---@field highlight string|string[]
55

66
local M = {}
77

88
---@param spec render.md.debug.ValidatorSpec
99
function M.validate(spec)
1010
require('render-markdown.config.base').validate(spec)
11-
spec:type('icon', 'string')
11+
spec:list('icon', 'string', 'string')
1212
spec:type('repeat_linebreak', 'boolean')
13-
spec:type('highlight', 'string')
13+
spec:list('highlight', 'string', 'string')
1414
spec:check()
1515
end
1616

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.3.2'
8+
M.version = '8.3.3'
99

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

lua/render-markdown/lib/list.lua

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@
22
local M = {}
33

44
---@generic T
5-
---@param values T[]
5+
---@param values `T`|T[]
66
---@param index integer
77
---@return T|nil
88
function M.cycle(values, index)
9-
if #values == 0 then
10-
return nil
9+
if type(values) == 'table' then
10+
if #values == 0 then
11+
return nil
12+
end
13+
return values[((index - 1) % #values) + 1]
14+
else
15+
return values
1116
end
12-
return values[((index - 1) % #values) + 1]
1317
end
1418

1519
---@generic T

lua/render-markdown/lib/node.lua

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,16 @@ end
5555

5656
---@return integer[]
5757
function Node:sections()
58-
local result, levels, section = {}, 0, self:parent('section')
58+
local result = {} ---@type integer[]
59+
local levels = 0
60+
local section = self:parent('section')
5961
while section ~= nil do
6062
local level = section:level(false)
6163
result[level] = section:sibling_count('section', level)
6264
levels = math.max(levels, level)
6365
section = section:parent('section')
6466
end
65-
-- Fill in any heading level gaps with 0
67+
-- fill in any heading level gaps with 0
6668
for i = 1, levels do
6769
result[i] = result[i] or 0
6870
end
@@ -87,12 +89,15 @@ end
8789
---@param target string
8890
---@return integer, render.md.Node?
8991
function Node:level_in_section(target)
90-
local parent, level, root = self.node:parent(), 0, nil
91-
while parent ~= nil and parent:type() ~= 'section' do
92-
if parent:type() == target then
93-
level, root = level + 1, parent
92+
local level = 0
93+
local root = nil ---@type TSNode?
94+
local node = self.node ---@type TSNode?
95+
while node ~= nil and node:type() ~= 'section' do
96+
if node:type() == target then
97+
level = level + 1
98+
root = node
9499
end
95-
parent = parent:parent()
100+
node = node:parent()
96101
end
97102
return level, root ~= nil and self:create(root) or nil
98103
end
@@ -113,12 +118,12 @@ end
113118
---@param target string
114119
---@return render.md.Node?
115120
function Node:sibling(target)
116-
local sibling = self.node:next_sibling()
117-
while sibling ~= nil do
118-
if sibling:type() == target then
119-
return self:create(sibling)
121+
local node = self.node ---@type TSNode?
122+
while node ~= nil do
123+
if node:type() == target then
124+
return self:create(node)
120125
end
121-
sibling = sibling:next_sibling()
126+
node = node:next_sibling()
122127
end
123128
return nil
124129
end
@@ -127,14 +132,15 @@ end
127132
---@param level? integer
128133
---@return integer
129134
function Node:sibling_count(target, level)
130-
local count, sibling = 1, self.node:prev_sibling()
135+
local count = 0
136+
local node = self.node ---@type TSNode?
131137
while
132-
sibling ~= nil
133-
and sibling:type() == target
134-
and (level == nil or self:create(sibling):level(false) == level)
138+
node ~= nil
139+
and node:type() == target
140+
and (level == nil or self:create(node):level(false) == level)
135141
do
136142
count = count + 1
137-
sibling = sibling:prev_sibling()
143+
node = node:prev_sibling()
138144
end
139145
return count
140146
end
@@ -150,10 +156,10 @@ end
150156
---@param target_row? integer
151157
---@return render.md.Node?
152158
function Node:child(target_type, target_row)
153-
for child in self.node:iter_children() do
154-
if child:type() == target_type then
155-
if target_row == nil or child:range() == target_row then
156-
return self:create(child)
159+
for node in self.node:iter_children() do
160+
if node:type() == target_type then
161+
if target_row == nil or node:range() == target_row then
162+
return self:create(node)
157163
end
158164
end
159165
end

lua/render-markdown/render/quote.lua

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
local Base = require('render-markdown.render.base')
2+
local List = require('render-markdown.lib.list')
23
local ts = require('render-markdown.integ.ts')
34

45
---@class render.md.quote.Data
56
---@field query vim.treesitter.Query
7+
---@field level integer
68
---@field icon string
79
---@field highlight string
810
---@field repeat_linebreak? boolean
@@ -18,44 +20,56 @@ function Render:setup()
1820
if self.context:skip(config) then
1921
return false
2022
end
21-
23+
local level = self.node:level_in_section('block_quote')
2224
local callout = self.context:get_callout(self.node.start_row)
23-
2425
self.data = {
2526
query = ts.parse(
2627
'markdown',
2728
[[
28-
[
29-
(block_quote_marker)
30-
(block_continuation)
31-
] @quote_marker
29+
(block_quote_marker) @marker
30+
(block_continuation) @continuation
3231
]]
3332
),
34-
icon = callout ~= nil and callout.quote_icon or config.icon,
35-
highlight = callout ~= nil and callout.highlight or config.highlight,
33+
level = level,
34+
icon = callout ~= nil and callout.quote_icon
35+
or assert(List.cycle(config.icon, level)),
36+
highlight = callout ~= nil and callout.highlight
37+
or assert(List.cycle(config.highlight, level)),
3638
repeat_linebreak = config.repeat_linebreak or nil,
3739
}
38-
3940
return true
4041
end
4142

4243
function Render:render()
4344
self.context:query(self.node:get(), self.data.query, function(capture, node)
44-
assert(
45-
capture == 'quote_marker',
46-
'Unhandled quote capture: ' .. capture
47-
)
48-
self:quote_marker(node)
45+
if capture == 'marker' then
46+
-- marker nodes are a single '>' at the start of a block quote
47+
-- overlay the only range if it is at the current level
48+
if node:level_in_section('block_quote') == self.data.level then
49+
self:quote(node, 1)
50+
end
51+
elseif capture == 'continuation' then
52+
-- continuation nodes are a group of '>'s inside a block quote
53+
-- overlay the range of the one at the current level if it exists
54+
self:quote(node, self.data.level)
55+
else
56+
error('Unhandled quote capture: ' .. capture)
57+
end
4958
end)
5059
end
5160

5261
---@private
5362
---@param node render.md.Node
54-
function Render:quote_marker(node)
55-
self.marks:over('quote', node, {
56-
virt_text = {
57-
{ node.text:gsub('>', self.data.icon), self.data.highlight },
58-
},
63+
---@param index integer
64+
function Render:quote(node, index)
65+
local range = node:find('>')[index]
66+
if range == nil then
67+
return
68+
end
69+
self.marks:add('quote', range[1], range[2], {
70+
end_row = range[3],
71+
end_col = range[4],
72+
virt_text = { { self.data.icon, self.data.highlight } },
5973
virt_text_pos = 'overlay',
6074
virt_text_repeat_linebreak = self.data.repeat_linebreak,
6175
})

lua/render-markdown/render/section.lua

Lines changed: 25 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -16,25 +16,23 @@ function Render:setup()
1616
if self.context:skip(self.info) then
1717
return false
1818
end
19-
2019
local current_level = self.node:level(false)
2120
local parent_level = math.max(self.node:level(true), self.info.skip_level)
22-
self.data = {
23-
level_change = current_level - parent_level,
24-
}
25-
26-
-- Nothing to do if there is not a change in level
27-
if self.data.level_change <= 0 then
21+
local level_change = current_level - parent_level
22+
-- nothing to do if there is not a change in level
23+
if level_change <= 0 then
2824
return false
2925
end
30-
26+
self.data = {
27+
level_change = level_change,
28+
}
3129
return true
3230
end
3331

3432
function Render:render()
35-
local start_row = self:get_start_row()
36-
local end_row = self:get_end_row()
37-
-- Each level stacks inline marks so we only need to process change in level
33+
local start_row = math.max(self.node.start_row + self:start_below(), 0)
34+
local end_row = self.node.end_row - 1 - self:end_above()
35+
-- each level stacks inline marks so we only add changes in level
3836
local virt_text = self:indent_line(false, self.data.level_change)
3937
for row = start_row, end_row do
4038
self.marks:add(false, row, 0, {
@@ -47,41 +45,34 @@ end
4745

4846
---@private
4947
---@return integer
50-
function Render:get_start_row()
48+
function Render:start_below()
5149
if self.info.skip_heading then
52-
-- Exclude any lines potentially used by section heading
53-
local second = self.node:line('first', 1)
54-
local offset = Str.width(second) == 0 and 1 or 0
55-
return self.node.start_row + 1 + offset
50+
-- exclude second line of current section if empty
51+
local empty = Str.width(self.node:line('first', 1)) == 0
52+
return empty and 2 or 1
5653
else
57-
-- Include last empty line in previous section
58-
-- Exclude if it is the only empty line in that section
59-
local above = self.node:line('above', 1)
60-
local two_above = self.node:line('above', 2)
61-
local above_is_empty = Str.width(above) == 0
62-
local two_above_is_section = self:is_section(two_above)
63-
local offset = (above_is_empty and not two_above_is_section) and 1 or 0
64-
return math.max(self.node.start_row - offset, 0)
54+
-- include last line of previous section if empty
55+
-- skip if it is the only line in the previous section
56+
local empty = Str.width(self.node:line('above', 1)) == 0
57+
local only = self:section(self.node:line('above', 2))
58+
return (empty and not only) and -1 or 0
6559
end
6660
end
6761

6862
---@private
6963
---@return integer
70-
function Render:get_end_row()
71-
-- Exclude last empty line in current section
72-
-- Include if it is the only empty line of the last subsection
73-
local last = self.node:line('last', 0)
74-
local second_last = self.node:line('last', 1)
75-
local last_is_empty = Str.width(last) == 0
76-
local second_last_is_section = self:is_section(second_last)
77-
local offset = (last_is_empty and not second_last_is_section) and 1 or 0
78-
return self.node.end_row - 1 - offset
64+
function Render:end_above()
65+
-- exclude last line of current section if empty
66+
-- skip if it is the only line in the last nested section
67+
local empty = Str.width(self.node:line('last', 0)) == 0
68+
local only = self:section(self.node:line('last', 1))
69+
return (empty and not only) and 1 or 0
7970
end
8071

8172
---@private
8273
---@param line? string
8374
---@return boolean
84-
function Render:is_section(line)
75+
function Render:section(line)
8576
return line ~= nil and vim.startswith(line, '#')
8677
end
8778

lua/render-markdown/types.lua

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,9 +245,9 @@
245245
---@field filler? string
246246

247247
---@class (exact) render.md.quote.UserConfig: render.md.base.UserConfig
248-
---@field icon? string
248+
---@field icon? string|string[]
249249
---@field repeat_linebreak? boolean
250-
---@field highlight? string
250+
---@field highlight? string|string[]
251251

252252
---@class (exact) render.md.sign.UserConfig
253253
---@field enabled? boolean

tests/box_dash_quote_spec.lua

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ describe('box_dash_quote.md', function()
4040
virt_text_pos = 'overlay',
4141
})
4242

43-
marks:add(row:inc(2), row:get(), 0, 4, util.quote(' %s ', 'RmQuote'))
44-
marks:add(row:inc(), row:get(), 0, 4, util.quote(' %s ', 'RmQuote'))
43+
marks:add(row:inc(2), row:get(), 2, 3, util.quote('RmQuote'))
44+
marks:add(row:inc(), row:get(), 2, 3, util.quote('RmQuote'))
4545

4646
util.assert_view(marks, {
4747
'󰫎 󰲡 Checkbox / Dash / Quote',

0 commit comments

Comments
 (0)