Skip to content

Commit

Permalink
Refactor indentexpr() to fix noindent indentation for lists. (#597)
Browse files Browse the repository at this point in the history
Co-authored-by: troiganto <[email protected]>
  • Loading branch information
troiganto and troiganto authored Oct 30, 2023
1 parent a14e1e5 commit 47b2978
Show file tree
Hide file tree
Showing 5 changed files with 279 additions and 34 deletions.
93 changes: 61 additions & 32 deletions lua/orgmode/org/indent.lua
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,24 @@ local get_matches = ts_utils.memoize_by_buf_tick(function(bufnr)
matches[range.start.line + 1] = opts
end

if type == 'list' then
local first_list_item = node:named_child(0)
local first_list_item_linenr = first_list_item:start()
local first_item_indent = vim.fn.indent(first_list_item_linenr + 1)
opts.indent = first_item_indent
if type == 'listitem' then
local content = node:named_child(1)
if content then
local content_linenr, content_indent = content:start()
if content_linenr == range.start.line then
opts.overhang = content_indent - opts.indent
end
end
if not opts.overhang then
local bullet = node:named_child(0)
opts.overhang = vim.treesitter.get_node_text(bullet, bufnr):len() + 1
end

local parent = node:parent()
while parent and parent:type() ~= 'section' and parent:type() ~= 'listitem' do
parent = parent:parent()
end
opts.nesting_parent_linenr = parent and (parent:start() + 1)

for i = range.start.line, range['end'].line - 1 do
matches[i + 1] = opts
Expand All @@ -46,9 +59,6 @@ local get_matches = ts_utils.memoize_by_buf_tick(function(bufnr)
local parent = node:parent()
while parent and parent:type() ~= 'section' do
parent = parent:parent()
if not parent then
break
end
end
if parent then
local headline = parent:named_child('headline')
Expand Down Expand Up @@ -106,20 +116,16 @@ local function foldexpr()
return '='
end

local function get_is_list_item(line)
local line_numbered_list_item = line:match('^%s*(%d+[%)%.]%s+)')
local line_unordered_list_item = line:match('^%s*([%+%-]%s+)')
return line_numbered_list_item or line_unordered_list_item
end

local function indentexpr()
local function indentexpr(linenr, mode)
linenr = linenr or vim.v.lnum
mode = mode or vim.fn.mode()
local noindent_mode = config.org_indent_mode == 'noindent'
query = query or vim.treesitter.query.get('org', 'org_indent')

local prev_linenr = vim.fn.prevnonblank(vim.v.lnum - 1)
local prev_linenr = vim.fn.prevnonblank(linenr - 1)

local matches = get_matches(0)
local match = matches[vim.v.lnum]
local match = matches[linenr]
local prev_line_match = matches[prev_linenr]

if not match and not prev_line_match then
Expand All @@ -140,26 +146,49 @@ local function indentexpr()
return 0
end

if match.type == 'list' and prev_line_match.type == 'list' then
local prev_line_list_item = get_is_list_item(vim.fn.getline(prev_linenr))
local cur_line_list_item = get_is_list_item(vim.fn.getline(vim.v.lnum))

if cur_line_list_item then
local diff = match.indent - vim.fn.indent(match.line_nr)
local indent = vim.fn.indent(vim.v.lnum)
return indent - diff
if match.type == 'listitem' then
-- We first figure out the indent of the first line of a listitem. Then we
-- check if we're on the first line or a "hanging" line. In the latter
-- case, we add the overhang.
local first_line_indent
local parent_linenr = match.nesting_parent_linenr
if parent_linenr then
local parent_match = matches[parent_linenr]
if parent_match.type == 'listitem' then
-- Nested listitem. Because two listitems cannot start on the same line,
-- we simply fetch the parent's indentation and add its overhang.
-- Don't use parent_match.indent, it might be stale if the parent
-- already got reindented.
first_line_indent = vim.fn.indent(parent_linenr) + parent_match.overhang
elseif parent_match.type == 'headline' and not noindent_mode then
-- Un-nested list inside a section, indent according to section.
first_line_indent = parent_match.indent
else
-- Noindent mode.
first_line_indent = 0
end
else
-- Top-level list before the first headline.
first_line_indent = 0
end

if prev_line_list_item then
return vim.fn.indent(prev_linenr) + prev_line_list_item:len()
-- Add overhang if this is a hanging line.
if linenr ~= match.line_nr then
return first_line_indent + match.overhang
end
return first_line_indent
end

if prev_line_match.type == 'list' and match.type ~= 'list' then
local prev_line_list_item = get_is_list_item(vim.fn.getline(prev_linenr))
if prev_line_list_item then
return vim.fn.indent(prev_linenr) + prev_line_list_item:len()
-- In insert mode, we also count the non-listitem line *after* a listitem as
-- part of the listitem. Keep in mind that double empty lines end a list as
-- per Orgmode syntax.
if mode:match('^[iR]') and prev_line_match.type == 'listitem' and linenr - prev_linenr < 3 then
-- After the first line of a listitem, we have to add the overhang to the
-- listitem's own base indent. After all further lines, we can simply copy
-- the indentation.
if prev_linenr == prev_line_match.line_nr then
return vim.fn.indent(prev_linenr) + prev_line_match.overhang
end
return vim.fn.indent(prev_linenr)
end

if noindent_mode then
Expand Down
2 changes: 1 addition & 1 deletion queries/org/org_indent.scm
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
(headline) @OrgIndentHeadline
(body (list) @OrgList)
(listitem) @OrgListItem
(body (paragraph) @OrgParagraph)
(body (drawer) @OrgDrawer)
(section (property_drawer) @OrgPropertyDrawer)
Expand Down
2 changes: 1 addition & 1 deletion scripts/gendoc.lua
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ local files = {
local destination = 'doc/orgmode_api.txt'

vim.fn.system(('lemmy-help %s > %s'):format(table.concat(files, ' '), destination))
vim.cmd[[qa!]]
vim.cmd([[qa!]])
1 change: 1 addition & 0 deletions tests/minimal_init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ M.setup({
vim.opt.runtimepath:prepend(vim.fn.fnamemodify(base_root_path, ':h'))
vim.opt.termguicolors = true
vim.opt.swapfile = false
vim.opt.expandtab = true -- Accommodates some deep nesting in indent_spec.lua
vim.cmd.language('en_US.utf-8')
vim.env.TZ = 'Europe/London'
vim.g.mapleader = ','
Expand Down
215 changes: 215 additions & 0 deletions tests/plenary/org/indent_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
local config = require('orgmode.config')
local Indent = require('orgmode.org.indent')
local helpers = require('tests.plenary.ui.helpers')

-- Helper assert function.
local function expect_whole_buffer(expected)
assert.are.same(expected, vim.api.nvim_buf_get_lines(0, 0, -1, false))
end

-- We want to run all tests under both values for `org_indent_mode`: "indent"
-- and "noindent". So it is easier to put all tests into test functions and
-- check the indent mode, then run them under two different `describe()`.

local function test_full_reindent()
local unformatted_file = {
'* TODO First task',
'SCHEDULED: <1970-01-01 Thu>',
'',
'1. Ordered list',
' a) nested list',
' over-indented',
' over-indented',
' b) nested list',
' under-indented',
'2. Ordered list',
'Not part of the list',
'',
'** Second task',
' DEADLINE: <1970-01-01 Thu>',
'',
'- Unordered list',
' + nested list',
' over-indented',
' over-indented',
' + nested list',
' under-indented',
'- unordered list',
' + nested list',
' * triple nested list',
' continuation',
' part of the first-level list',
'Not part of the list',
}
helpers.load_file_content(unformatted_file)
vim.cmd([[silent norm 0gg=G]])
local expected
if config.org_indent_mode == 'indent' then
expected = {
'* TODO First task',
' SCHEDULED: <1970-01-01 Thu>',
'',
' 1. Ordered list',
' a) nested list',
' over-indented',
' over-indented',
' b) nested list',
' under-indented',
' 2. Ordered list',
' Not part of the list',
'',
'** Second task',
' DEADLINE: <1970-01-01 Thu>',
'',
' - Unordered list',
' + nested list',
' over-indented',
' over-indented',
' + nested list',
' under-indented',
' - unordered list',
' + nested list',
' * triple nested list',
' continuation',
' part of the first-level list',
' Not part of the list',
}
elseif config.org_indent_mode == 'noindent' then
expected = {
'* TODO First task',
'SCHEDULED: <1970-01-01 Thu>',
'',
'1. Ordered list',
' a) nested list',
' over-indented',
' over-indented',
' b) nested list',
' under-indented',
'2. Ordered list',
'Not part of the list',
'',
'** Second task',
'DEADLINE: <1970-01-01 Thu>',
'',
'- Unordered list',
' + nested list',
' over-indented',
' over-indented',
' + nested list',
' under-indented',
'- unordered list',
' + nested list',
' * triple nested list',
' continuation',
' part of the first-level list',
'Not part of the list',
}
end
expect_whole_buffer(expected)
end

local function test_newly_written_list()
helpers.load_file_content({})
local user_input = vim.api.nvim_replace_termcodes('i- new item<CR>second line<CR>third line<Esc>', true, true, true)
vim.api.nvim_feedkeys(user_input, 'ntix', false)
local expected
if config.org_indent_mode == 'indent' then
expected = {
'- new item',
' second line',
' third line',
}
elseif config.org_indent_mode == 'noindent' then
expected = {
'- new item',
' second line',
' third line',
}
end
expect_whole_buffer(expected)
end

local function test_insertion_to_an_existing_list()
helpers.load_file_content({ '- first item', '- third item' })
vim.cmd([[normal! o]])
local user_input = vim.api.nvim_replace_termcodes('i- new item<CR>second line<CR>third line<Esc>', true, true, true)
vim.api.nvim_feedkeys(user_input, 'ntix', false)
local expected
if config.org_indent_mode == 'indent' then
expected = {
'- first item',
'- new item',
' second line',
' third line',
'- third item',
}
elseif config.org_indent_mode == 'noindent' then
expected = {
'- first item',
'- new item',
' second line',
' third line',
'- third item',
}
end
expect_whole_buffer(expected)
end

local function test_add_line_breaks_to_existing_file()
helpers.load_file_content({ '- first item', '- second item' })
local user_input = vim.api.nvim_replace_termcodes('wwi<CR><Esc><Down><Right>i<CR><Esc>', true, true, true)
vim.api.nvim_feedkeys(user_input, 'ntix', false)
local expected = {
'- first ',
' item',
'- ',
' second item',
}
expect_whole_buffer(expected)
end

-- The actual tests are here.

describe('with "indent",', function()
before_each(function()
config:extend({ org_indent_mode = 'indent' })
end)

it('"0gg=G" reindents the whole file', function()
test_full_reindent()
end)

it('a newly written list is well indented', function()
test_newly_written_list()
end)

it('insertion to an existing list is well indented', function()
test_insertion_to_an_existing_list()
end)

it('adding line breaks to list items maintains indent', function()
test_add_line_breaks_to_existing_file()
end)
end)

describe('with "noindent",', function()
before_each(function()
config:extend({ org_indent_mode = 'noindent' })
end)

it('"0gg=G" reindents the whole file', function()
test_full_reindent()
end)

it('a newly written list is well indented', function()
test_newly_written_list()
end)

it('insertion into an existing list is well indented', function()
test_insertion_to_an_existing_list()
end)

it('adding line breaks to list items maintains indent', function()
test_add_line_breaks_to_existing_file()
end)
end)

0 comments on commit 47b2978

Please sign in to comment.