Skip to content

Commit 47b2978

Browse files
Übertreiberbroken-pen
andauthored
Refactor indentexpr() to fix noindent indentation for lists. (#597)
Co-authored-by: troiganto <[email protected]>
1 parent a14e1e5 commit 47b2978

File tree

5 files changed

+279
-34
lines changed

5 files changed

+279
-34
lines changed

lua/orgmode/org/indent.lua

Lines changed: 61 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,24 @@ local get_matches = ts_utils.memoize_by_buf_tick(function(bufnr)
3030
matches[range.start.line + 1] = opts
3131
end
3232

33-
if type == 'list' then
34-
local first_list_item = node:named_child(0)
35-
local first_list_item_linenr = first_list_item:start()
36-
local first_item_indent = vim.fn.indent(first_list_item_linenr + 1)
37-
opts.indent = first_item_indent
33+
if type == 'listitem' then
34+
local content = node:named_child(1)
35+
if content then
36+
local content_linenr, content_indent = content:start()
37+
if content_linenr == range.start.line then
38+
opts.overhang = content_indent - opts.indent
39+
end
40+
end
41+
if not opts.overhang then
42+
local bullet = node:named_child(0)
43+
opts.overhang = vim.treesitter.get_node_text(bullet, bufnr):len() + 1
44+
end
45+
46+
local parent = node:parent()
47+
while parent and parent:type() ~= 'section' and parent:type() ~= 'listitem' do
48+
parent = parent:parent()
49+
end
50+
opts.nesting_parent_linenr = parent and (parent:start() + 1)
3851

3952
for i = range.start.line, range['end'].line - 1 do
4053
matches[i + 1] = opts
@@ -46,9 +59,6 @@ local get_matches = ts_utils.memoize_by_buf_tick(function(bufnr)
4659
local parent = node:parent()
4760
while parent and parent:type() ~= 'section' do
4861
parent = parent:parent()
49-
if not parent then
50-
break
51-
end
5262
end
5363
if parent then
5464
local headline = parent:named_child('headline')
@@ -106,20 +116,16 @@ local function foldexpr()
106116
return '='
107117
end
108118

109-
local function get_is_list_item(line)
110-
local line_numbered_list_item = line:match('^%s*(%d+[%)%.]%s+)')
111-
local line_unordered_list_item = line:match('^%s*([%+%-]%s+)')
112-
return line_numbered_list_item or line_unordered_list_item
113-
end
114-
115-
local function indentexpr()
119+
local function indentexpr(linenr, mode)
120+
linenr = linenr or vim.v.lnum
121+
mode = mode or vim.fn.mode()
116122
local noindent_mode = config.org_indent_mode == 'noindent'
117123
query = query or vim.treesitter.query.get('org', 'org_indent')
118124

119-
local prev_linenr = vim.fn.prevnonblank(vim.v.lnum - 1)
125+
local prev_linenr = vim.fn.prevnonblank(linenr - 1)
120126

121127
local matches = get_matches(0)
122-
local match = matches[vim.v.lnum]
128+
local match = matches[linenr]
123129
local prev_line_match = matches[prev_linenr]
124130

125131
if not match and not prev_line_match then
@@ -140,26 +146,49 @@ local function indentexpr()
140146
return 0
141147
end
142148

143-
if match.type == 'list' and prev_line_match.type == 'list' then
144-
local prev_line_list_item = get_is_list_item(vim.fn.getline(prev_linenr))
145-
local cur_line_list_item = get_is_list_item(vim.fn.getline(vim.v.lnum))
146-
147-
if cur_line_list_item then
148-
local diff = match.indent - vim.fn.indent(match.line_nr)
149-
local indent = vim.fn.indent(vim.v.lnum)
150-
return indent - diff
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
151173
end
152-
153-
if prev_line_list_item then
154-
return vim.fn.indent(prev_linenr) + prev_line_list_item:len()
174+
-- Add overhang if this is a hanging line.
175+
if linenr ~= match.line_nr then
176+
return first_line_indent + match.overhang
155177
end
178+
return first_line_indent
156179
end
157180

158-
if prev_line_match.type == 'list' and match.type ~= 'list' then
159-
local prev_line_list_item = get_is_list_item(vim.fn.getline(prev_linenr))
160-
if prev_line_list_item then
161-
return vim.fn.indent(prev_linenr) + prev_line_list_item:len()
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
162190
end
191+
return vim.fn.indent(prev_linenr)
163192
end
164193

165194
if noindent_mode then

queries/org/org_indent.scm

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
(headline) @OrgIndentHeadline
2-
(body (list) @OrgList)
2+
(listitem) @OrgListItem
33
(body (paragraph) @OrgParagraph)
44
(body (drawer) @OrgDrawer)
55
(section (property_drawer) @OrgPropertyDrawer)

scripts/gendoc.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@ local files = {
88
local destination = 'doc/orgmode_api.txt'
99

1010
vim.fn.system(('lemmy-help %s > %s'):format(table.concat(files, ' '), destination))
11-
vim.cmd[[qa!]]
11+
vim.cmd([[qa!]])

tests/minimal_init.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ M.setup({
8585
vim.opt.runtimepath:prepend(vim.fn.fnamemodify(base_root_path, ':h'))
8686
vim.opt.termguicolors = true
8787
vim.opt.swapfile = false
88+
vim.opt.expandtab = true -- Accommodates some deep nesting in indent_spec.lua
8889
vim.cmd.language('en_US.utf-8')
8990
vim.env.TZ = 'Europe/London'
9091
vim.g.mapleader = ','

tests/plenary/org/indent_spec.lua

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
local config = require('orgmode.config')
2+
local Indent = require('orgmode.org.indent')
3+
local helpers = require('tests.plenary.ui.helpers')
4+
5+
-- Helper assert function.
6+
local function expect_whole_buffer(expected)
7+
assert.are.same(expected, vim.api.nvim_buf_get_lines(0, 0, -1, false))
8+
end
9+
10+
-- We want to run all tests under both values for `org_indent_mode`: "indent"
11+
-- and "noindent". So it is easier to put all tests into test functions and
12+
-- check the indent mode, then run them under two different `describe()`.
13+
14+
local function test_full_reindent()
15+
local unformatted_file = {
16+
'* TODO First task',
17+
'SCHEDULED: <1970-01-01 Thu>',
18+
'',
19+
'1. Ordered list',
20+
' a) nested list',
21+
' over-indented',
22+
' over-indented',
23+
' b) nested list',
24+
' under-indented',
25+
'2. Ordered list',
26+
'Not part of the list',
27+
'',
28+
'** Second task',
29+
' DEADLINE: <1970-01-01 Thu>',
30+
'',
31+
'- Unordered list',
32+
' + nested list',
33+
' over-indented',
34+
' over-indented',
35+
' + nested list',
36+
' under-indented',
37+
'- unordered list',
38+
' + nested list',
39+
' * triple nested list',
40+
' continuation',
41+
' part of the first-level list',
42+
'Not part of the list',
43+
}
44+
helpers.load_file_content(unformatted_file)
45+
vim.cmd([[silent norm 0gg=G]])
46+
local expected
47+
if config.org_indent_mode == 'indent' then
48+
expected = {
49+
'* TODO First task',
50+
' SCHEDULED: <1970-01-01 Thu>',
51+
'',
52+
' 1. Ordered list',
53+
' a) nested list',
54+
' over-indented',
55+
' over-indented',
56+
' b) nested list',
57+
' under-indented',
58+
' 2. Ordered list',
59+
' Not part of the list',
60+
'',
61+
'** Second task',
62+
' DEADLINE: <1970-01-01 Thu>',
63+
'',
64+
' - Unordered list',
65+
' + nested list',
66+
' over-indented',
67+
' over-indented',
68+
' + nested list',
69+
' under-indented',
70+
' - unordered list',
71+
' + nested list',
72+
' * triple nested list',
73+
' continuation',
74+
' part of the first-level list',
75+
' Not part of the list',
76+
}
77+
elseif config.org_indent_mode == 'noindent' then
78+
expected = {
79+
'* TODO First task',
80+
'SCHEDULED: <1970-01-01 Thu>',
81+
'',
82+
'1. Ordered list',
83+
' a) nested list',
84+
' over-indented',
85+
' over-indented',
86+
' b) nested list',
87+
' under-indented',
88+
'2. Ordered list',
89+
'Not part of the list',
90+
'',
91+
'** Second task',
92+
'DEADLINE: <1970-01-01 Thu>',
93+
'',
94+
'- Unordered list',
95+
' + nested list',
96+
' over-indented',
97+
' over-indented',
98+
' + nested list',
99+
' under-indented',
100+
'- unordered list',
101+
' + nested list',
102+
' * triple nested list',
103+
' continuation',
104+
' part of the first-level list',
105+
'Not part of the list',
106+
}
107+
end
108+
expect_whole_buffer(expected)
109+
end
110+
111+
local function test_newly_written_list()
112+
helpers.load_file_content({})
113+
local user_input = vim.api.nvim_replace_termcodes('i- new item<CR>second line<CR>third line<Esc>', true, true, true)
114+
vim.api.nvim_feedkeys(user_input, 'ntix', false)
115+
local expected
116+
if config.org_indent_mode == 'indent' then
117+
expected = {
118+
'- new item',
119+
' second line',
120+
' third line',
121+
}
122+
elseif config.org_indent_mode == 'noindent' then
123+
expected = {
124+
'- new item',
125+
' second line',
126+
' third line',
127+
}
128+
end
129+
expect_whole_buffer(expected)
130+
end
131+
132+
local function test_insertion_to_an_existing_list()
133+
helpers.load_file_content({ '- first item', '- third item' })
134+
vim.cmd([[normal! o]])
135+
local user_input = vim.api.nvim_replace_termcodes('i- new item<CR>second line<CR>third line<Esc>', true, true, true)
136+
vim.api.nvim_feedkeys(user_input, 'ntix', false)
137+
local expected
138+
if config.org_indent_mode == 'indent' then
139+
expected = {
140+
'- first item',
141+
'- new item',
142+
' second line',
143+
' third line',
144+
'- third item',
145+
}
146+
elseif config.org_indent_mode == 'noindent' then
147+
expected = {
148+
'- first item',
149+
'- new item',
150+
' second line',
151+
' third line',
152+
'- third item',
153+
}
154+
end
155+
expect_whole_buffer(expected)
156+
end
157+
158+
local function test_add_line_breaks_to_existing_file()
159+
helpers.load_file_content({ '- first item', '- second item' })
160+
local user_input = vim.api.nvim_replace_termcodes('wwi<CR><Esc><Down><Right>i<CR><Esc>', true, true, true)
161+
vim.api.nvim_feedkeys(user_input, 'ntix', false)
162+
local expected = {
163+
'- first ',
164+
' item',
165+
'- ',
166+
' second item',
167+
}
168+
expect_whole_buffer(expected)
169+
end
170+
171+
-- The actual tests are here.
172+
173+
describe('with "indent",', function()
174+
before_each(function()
175+
config:extend({ org_indent_mode = 'indent' })
176+
end)
177+
178+
it('"0gg=G" reindents the whole file', function()
179+
test_full_reindent()
180+
end)
181+
182+
it('a newly written list is well indented', function()
183+
test_newly_written_list()
184+
end)
185+
186+
it('insertion to an existing list is well indented', function()
187+
test_insertion_to_an_existing_list()
188+
end)
189+
190+
it('adding line breaks to list items maintains indent', function()
191+
test_add_line_breaks_to_existing_file()
192+
end)
193+
end)
194+
195+
describe('with "noindent",', function()
196+
before_each(function()
197+
config:extend({ org_indent_mode = 'noindent' })
198+
end)
199+
200+
it('"0gg=G" reindents the whole file', function()
201+
test_full_reindent()
202+
end)
203+
204+
it('a newly written list is well indented', function()
205+
test_newly_written_list()
206+
end)
207+
208+
it('insertion into an existing list is well indented', function()
209+
test_insertion_to_an_existing_list()
210+
end)
211+
212+
it('adding line breaks to list items maintains indent', function()
213+
test_add_line_breaks_to_existing_file()
214+
end)
215+
end)

0 commit comments

Comments
 (0)