Skip to content

Commit 92bfc3f

Browse files
authored
fix: improve block and list indentations (#629)
1 parent cbb10d4 commit 92bfc3f

File tree

2 files changed

+214
-87
lines changed

2 files changed

+214
-87
lines changed

lua/orgmode/org/indent.lua

Lines changed: 145 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,88 @@
11
local config = require('orgmode.config')
2+
local headline_lib = require('orgmode.treesitter.headline')
23
local ts_utils = require('nvim-treesitter.ts_utils')
34
local query = nil
45

6+
local function get_indent_pad(linenr)
7+
local indent_mode = config.org_indent_mode == 'indent'
8+
if indent_mode then
9+
local headline = headline_lib.from_cursor({ linenr, 0 })
10+
if not headline then
11+
return 0
12+
end
13+
return headline:level() + 1
14+
end
15+
return 0
16+
end
17+
18+
local function get_indent_for_match(matches, linenr, mode)
19+
linenr = linenr or vim.v.lnum
20+
mode = mode or vim.fn.mode()
21+
local prev_linenr = vim.fn.prevnonblank(linenr - 1)
22+
local match = matches[linenr]
23+
local prev_line_match = matches[prev_linenr]
24+
local indent = 0
25+
26+
if not match and not prev_line_match then
27+
return indent + get_indent_pad(linenr)
28+
end
29+
30+
match = match or {}
31+
prev_line_match = prev_line_match or {}
32+
33+
if match.type == 'headline' then
34+
-- We ensure we check headlines (even if a bit redundant) to ensure nothing else is checked below
35+
return 0
36+
end
37+
if match.type == 'listitem' then
38+
-- We first figure out the indent of the first line of a listitem. Then we
39+
-- check if we're on the first line or a "hanging" line. In the latter
40+
-- case, we add the overhang.
41+
local first_line_indent = nil
42+
local parent_linenr = match.nesting_parent_linenr
43+
if parent_linenr then
44+
local parent_match = matches[parent_linenr]
45+
if parent_match.type == 'listitem' then
46+
-- Nested listitem. We recursively find the correct indent for this
47+
-- based on its parents correct indentation level.
48+
first_line_indent = vim.fn.indent(parent_linenr) + parent_match.overhang
49+
end
50+
end
51+
-- If the first_line_indent wasn't found then this is the root of the list, as such we just pad accordingly
52+
indent = first_line_indent or (0 + get_indent_pad(linenr))
53+
-- If the current line is hanging content as part of the listitem but not on the same line we want to indent it
54+
-- such that it's in line with the general content body, not the bullet.
55+
--
56+
-- - I am the "first" line listitem
57+
-- I am the content body as part of the listitem, but on a different line!
58+
if linenr ~= match.line_nr then
59+
indent = indent + match.overhang
60+
end
61+
return indent
62+
end
63+
if mode:match('^[iR]') and prev_line_match.type == 'listitem' and linenr - prev_linenr < 3 then
64+
-- In insert mode, we also count the non-listitem line *after* a listitem as
65+
-- part of the listitem. Keep in mind that double empty lines end a list as
66+
-- per Orgmode syntax.
67+
--
68+
-- After the first line of a listitem, we have to add the overhang to the
69+
-- listitem's own base indent. After all further lines, we can simply copy
70+
-- the indentation.
71+
indent = get_indent_for_match(matches, prev_linenr)
72+
if prev_linenr == prev_line_match.line_nr then
73+
indent = indent + prev_line_match.overhang
74+
end
75+
return indent
76+
end
77+
if match.indent_type == 'block' then
78+
-- Blocks do some precalculation of their own against the intended indent level of the parent. As such we just want
79+
-- to return their indent without any other modifications.
80+
return match.indent
81+
end
82+
83+
return indent + get_indent_pad(linenr)
84+
end
85+
586
local get_matches = ts_utils.memoize_by_buf_tick(function(bufnr)
687
local tree = vim.treesitter.get_parser(bufnr, 'org', {}):parse()
788
if not tree or not #tree then
@@ -47,27 +128,81 @@ local get_matches = ts_utils.memoize_by_buf_tick(function(bufnr)
47128
while parent and parent:type() ~= 'section' and parent:type() ~= 'listitem' do
48129
parent = parent:parent()
49130
end
131+
local prev_sibling = node:prev_sibling()
132+
opts.prev_sibling_linenr = prev_sibling and (prev_sibling:start() + 1)
50133
opts.nesting_parent_linenr = parent and (parent:start() + 1)
51134

52135
for i = range.start.line, range['end'].line - 1 do
53136
matches[i + 1] = opts
54137
end
55138
end
56139

57-
if type == 'paragraph' or type == 'drawer' or type == 'property_drawer' or type == 'block' then
58-
opts.indent_type = 'other'
140+
if type == 'block' then
141+
opts.indent_type = 'block'
59142
local parent = node:parent()
60-
while parent and parent:type() ~= 'section' do
143+
while parent and parent:type() ~= 'section' and parent:type() ~= 'listitem' do
61144
parent = parent:parent()
62145
end
63-
if parent then
64-
local headline = parent:named_child('headline')
65-
local stars = vim.treesitter.get_node_text(headline:field('stars')[1], bufnr):len()
66-
opts.indent = stars + 1
67-
for i = range.start.line, range['end'].line - 1 do
68-
matches[i + 1] = opts
146+
-- We want to find the difference in indentation level between the item to be indented and the parent node.
147+
-- If the item is in the block, we shouldn't change the indentation beyond how much we modify the indent of the
148+
-- block header and footer. This keeps code correctly indented in `BEGIN_SRC` blocks as well as ensuring
149+
-- `BEGIN_EXAMPLE` blocks don't have their indentation changed inside of them.
150+
local parent_linenr = parent:start() + 1
151+
local parent_indent = get_indent_for_match(matches, parent:start() + 1)
152+
153+
-- We want to align to the listitem body, not the bullet
154+
if parent:type() == 'listitem' then
155+
parent_indent = parent_indent + matches[parent_linenr].overhang
156+
else
157+
parent_indent = get_indent_pad(range.start.line + 1)
158+
end
159+
160+
local curr_header_indent = vim.fn.indent(range.start.line + 1)
161+
local header_indent_diff = curr_header_indent - parent_indent
162+
local new_header_indent = curr_header_indent - header_indent_diff
163+
-- Ensure the block footer is properly aligned with the header
164+
matches[range.start.line + 1] = vim.tbl_deep_extend('force', opts, {
165+
indent = new_header_indent,
166+
})
167+
matches[range['end'].line] = vim.tbl_deep_extend('force', opts, {
168+
indent = new_header_indent,
169+
})
170+
171+
local content_indent_pad = 0
172+
-- Only include the header line and the content. Do not include the footer in the loop.
173+
for i = range.start.line + 1, range['end'].line - 2 do
174+
local curr_indent = vim.fn.indent(i + 1)
175+
-- Correctly align the pad to the new header position if it was underindented
176+
local new_indent_pad = new_header_indent - curr_indent
177+
-- If the current content indentaion is less than the new header indent we want to increase all of the
178+
-- content by the largest difference in indentation between a given content line and the new header indent.
179+
if curr_indent < new_header_indent then
180+
content_indent_pad = math.max(new_indent_pad, content_indent_pad)
181+
else
182+
-- If the current content indentation is more than the new header indentation, but it was the current
183+
-- content indentation was less than the current header indent then we want to add some indentation onto
184+
-- the content by the largest negative difference (meaning -1 > -2 > -3 so take -1 as the pad).
185+
--
186+
-- We do a check for 0 here as we don't want to do a max of neg number against 0. 0 will always win. As
187+
-- such if the current pad is 0 just set to the new calculated pad.
188+
if content_indent_pad == 0 then
189+
content_indent_pad = new_indent_pad
190+
else
191+
content_indent_pad = math.max(new_indent_pad, content_indent_pad)
192+
end
69193
end
70194
end
195+
-- If any of the content is underindented relative to the header and footer, we need to indent all of the
196+
-- content until the most underindented content is equal in indention to the header and footer.
197+
--
198+
-- Only loop the content.
199+
for i = range.start.line + 1, range['end'].line - 2 do
200+
matches[i + 1] = vim.tbl_deep_extend('force', opts, {
201+
indent = vim.fn.indent(i + 1) + content_indent_pad,
202+
})
203+
end
204+
elseif type == 'paragraph' or type == 'drawer' or type == 'property_drawer' then
205+
opts.indent_type = 'other'
71206
end
72207
end
73208
end
@@ -119,87 +254,10 @@ end
119254
local function indentexpr(linenr, mode)
120255
linenr = linenr or vim.v.lnum
121256
mode = mode or vim.fn.mode()
122-
local noindent_mode = config.org_indent_mode == 'noindent'
123257
query = query or vim.treesitter.query.get('org', 'org_indent')
124-
125-
local prev_linenr = vim.fn.prevnonblank(linenr - 1)
126-
127258
local matches = get_matches(0)
128-
local match = matches[linenr]
129-
local prev_line_match = matches[prev_linenr]
130-
131-
if not match and not prev_line_match then
132-
return -1
133-
end
134-
135-
match = match or {}
136-
prev_line_match = prev_line_match or {}
137-
138-
if prev_line_match.type == 'headline' then
139-
if noindent_mode or (match.type == 'headline' and match.stars > 0) then
140-
return 0
141-
end
142-
return prev_line_match.indent
143-
end
144-
145-
if match.type == 'headline' then
146-
return 0
147-
end
148-
149-
if match.type == 'listitem' then
150-
-- We first figure out the indent of the first line of a listitem. Then we
151-
-- check if we're on the first line or a "hanging" line. In the latter
152-
-- case, we add the overhang.
153-
local first_line_indent
154-
local parent_linenr = match.nesting_parent_linenr
155-
if parent_linenr then
156-
local parent_match = matches[parent_linenr]
157-
if parent_match.type == 'listitem' then
158-
-- Nested listitem. Because two listitems cannot start on the same line,
159-
-- we simply fetch the parent's indentation and add its overhang.
160-
-- Don't use parent_match.indent, it might be stale if the parent
161-
-- already got reindented.
162-
first_line_indent = vim.fn.indent(parent_linenr) + parent_match.overhang
163-
elseif parent_match.type == 'headline' and not noindent_mode then
164-
-- Un-nested list inside a section, indent according to section.
165-
first_line_indent = parent_match.indent
166-
else
167-
-- Noindent mode.
168-
first_line_indent = 0
169-
end
170-
else
171-
-- Top-level list before the first headline.
172-
first_line_indent = 0
173-
end
174-
-- Add overhang if this is a hanging line.
175-
if linenr ~= match.line_nr then
176-
return first_line_indent + match.overhang
177-
end
178-
return first_line_indent
179-
end
180-
181-
-- In insert mode, we also count the non-listitem line *after* a listitem as
182-
-- part of the listitem. Keep in mind that double empty lines end a list as
183-
-- per Orgmode syntax.
184-
if mode:match('^[iR]') and prev_line_match.type == 'listitem' and linenr - prev_linenr < 3 then
185-
-- After the first line of a listitem, we have to add the overhang to the
186-
-- listitem's own base indent. After all further lines, we can simply copy
187-
-- the indentation.
188-
if prev_linenr == prev_line_match.line_nr then
189-
return vim.fn.indent(prev_linenr) + prev_line_match.overhang
190-
end
191-
return vim.fn.indent(prev_linenr)
192-
end
193-
194-
if noindent_mode then
195-
return 0
196-
end
197-
198-
if match.indent_type == 'other' then
199-
return match.indent
200-
end
201259

202-
return vim.fn.indent(prev_linenr)
260+
return get_indent_for_match(matches, linenr, mode)
203261
end
204262

205263
local function foldtext()

tests/plenary/org/indent_spec.lua

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,29 @@ local function test_full_reindent()
4040
' continuation',
4141
' part of the first-level list',
4242
'Not part of the list',
43+
'',
44+
'*** Incorrectly indented block',
45+
' #+BEGIN_SRC json',
46+
' {',
47+
' "key": "value",',
48+
' "another key": "another value"',
49+
' }',
50+
' #+END_SRC',
51+
'',
52+
' - Correctly reindents to list indentation level',
53+
' #+BEGIN_SRC json',
54+
' {',
55+
' "key": "value",',
56+
' "another key": "another value"',
57+
' }',
58+
'#+END_SRC',
59+
' - Correctly reindents when entire block overindented',
60+
' #+BEGIN_SRC json',
61+
' {',
62+
' "key": "value",',
63+
' "another key": "another value"',
64+
' }',
65+
' #+END_SRC',
4366
}
4467
helpers.load_file_content(unformatted_file)
4568
vim.cmd([[silent norm 0gg=G]])
@@ -73,6 +96,29 @@ local function test_full_reindent()
7396
' continuation',
7497
' part of the first-level list',
7598
' Not part of the list',
99+
'',
100+
'*** Incorrectly indented block',
101+
' #+BEGIN_SRC json',
102+
' {',
103+
' "key": "value",',
104+
' "another key": "another value"',
105+
' }',
106+
' #+END_SRC',
107+
'',
108+
' - Correctly reindents to list indentation level',
109+
' #+BEGIN_SRC json',
110+
' {',
111+
' "key": "value",',
112+
' "another key": "another value"',
113+
' }',
114+
' #+END_SRC',
115+
' - Correctly reindents when entire block overindented',
116+
' #+BEGIN_SRC json',
117+
' {',
118+
' "key": "value",',
119+
' "another key": "another value"',
120+
' }',
121+
' #+END_SRC',
76122
}
77123
elseif config.org_indent_mode == 'noindent' then
78124
expected = {
@@ -103,6 +149,29 @@ local function test_full_reindent()
103149
' continuation',
104150
' part of the first-level list',
105151
'Not part of the list',
152+
'',
153+
'*** Incorrectly indented block',
154+
'#+BEGIN_SRC json',
155+
'{',
156+
' "key": "value",',
157+
' "another key": "another value"',
158+
'}',
159+
'#+END_SRC',
160+
'',
161+
'- Correctly reindents to list indentation level',
162+
' #+BEGIN_SRC json',
163+
' {',
164+
' "key": "value",',
165+
' "another key": "another value"',
166+
' }',
167+
' #+END_SRC',
168+
'- Correctly reindents when entire block overindented',
169+
' #+BEGIN_SRC json',
170+
' {',
171+
' "key": "value",',
172+
' "another key": "another value"',
173+
' }',
174+
' #+END_SRC',
106175
}
107176
end
108177
expect_whole_buffer(expected)

0 commit comments

Comments
 (0)