From 083694548f805e586b11dc682525887eabc199c8 Mon Sep 17 00:00:00 2001 From: troiganto Date: Thu, 3 Aug 2023 22:50:59 +0200 Subject: [PATCH] Refactor indentexpr() to fix `noindent` indentation for lists. Closes #473. I tried doing small fixes to this code, but kept running into edge cases. Hence, this complete rewrite. :) The important points: - The queries in `indent.scm` no longer match on top-level (i.e un-nested) lists, but instead on list items of all levels. - List item indentation no longer relies on the previous non-empty line. Each list item stores whether it's in a top-level or a nested list and calculates its indent based on that. - The check whether we are in bulleted line or not no longer uses `str.match()`, since its pattern was buggy and forgot a few kinds of bullets. (namely, indented `*` bullets and `a.` ordered bullets) Instead, we compare the current line number to `match.line_nr`. We can do that because we query list items instead of lists now. There is an edge case when the user is appending to a list. We want that next line to be indented (see #472), but it's technically outside of the list. At the same time, if an unindented line follows a list, it should not become part of the list. The best solution I found for this was to make the behavior of `indentexpr()` depend on whether we are in insert mode. If yes, the line after a list is part of the list. If not, it isn't. The new code also correctly takes into account that two consecutive empty lines always end a preceding list. --- lua/orgmode/org/indent.lua | 85 ++++++++++++++++++++----------- queries/org/org_indent.scm | 2 +- tests/minimal_init.lua | 1 + tests/plenary/org/indent_spec.lua | 40 +++++++-------- 4 files changed, 78 insertions(+), 50 deletions(-) diff --git a/lua/orgmode/org/indent.lua b/lua/orgmode/org/indent.lua index d11d00cf0..0a088ad5f 100644 --- a/lua/orgmode/org/indent.lua +++ b/lua/orgmode/org/indent.lua @@ -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 @@ -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') @@ -106,12 +116,6 @@ 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(linenr, mode) linenr = linenr or vim.v.lnum mode = mode or vim.fn.mode() @@ -142,26 +146,49 @@ local function indentexpr(linenr, mode) 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(linenr)) - - if cur_line_list_item then - local diff = match.indent - vim.fn.indent(match.line_nr) - local indent = vim.fn.indent(linenr) - 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 diff --git a/queries/org/org_indent.scm b/queries/org/org_indent.scm index e10ebd3be..c9a78ad82 100644 --- a/queries/org/org_indent.scm +++ b/queries/org/org_indent.scm @@ -1,5 +1,5 @@ (headline) @OrgIndentHeadline -(body (list) @OrgList) +(listitem) @OrgListItem (body (paragraph) @OrgParagraph) (body (drawer) @OrgDrawer) (section (property_drawer) @OrgPropertyDrawer) diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua index 0c37302a8..4d3eda9fb 100644 --- a/tests/minimal_init.lua +++ b/tests/minimal_init.lua @@ -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 = ',' diff --git a/tests/plenary/org/indent_spec.lua b/tests/plenary/org/indent_spec.lua index 12df251af..13f5e004d 100644 --- a/tests/plenary/org/indent_spec.lua +++ b/tests/plenary/org/indent_spec.lua @@ -51,12 +51,12 @@ local function test_full_reindent() '', ' 1. Ordered list', ' a) nested list', - ' over-indented', - ' over-indented', + ' over-indented', + ' over-indented', ' b) nested list', - ' under-indented', + ' under-indented', ' 2. Ordered list', - ' Not part of the list', + ' Not part of the list', '', '** Second task', ' DEADLINE: <1970-01-01 Thu>', @@ -68,10 +68,10 @@ local function test_full_reindent() ' + nested list', ' under-indented', ' - unordered list', - ' + nested list', - ' * triple nested list', - ' continuation', - ' part of the first-level 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 @@ -81,12 +81,12 @@ local function test_full_reindent() '', '1. Ordered list', ' a) nested list', - 'over-indented', - 'over-indented', - 'b) nested list', - 'under-indented', + ' over-indented', + ' over-indented', + ' b) nested list', + ' under-indented', '2. Ordered list', - ' Not part of the list', + 'Not part of the list', '', '** Second task', 'DEADLINE: <1970-01-01 Thu>', @@ -94,14 +94,14 @@ local function test_full_reindent() '- Unordered list', ' + nested list', ' over-indented', - 'over-indented', + ' over-indented', ' + nested list', ' under-indented', '- unordered list', - ' + nested list', - ' * triple nested list', - 'continuation', - 'part of the first-level list', + ' + nested list', + ' * triple nested list', + ' continuation', + ' part of the first-level list', 'Not part of the list', } end @@ -123,7 +123,7 @@ local function test_newly_written_list() expected = { '- new item', ' second line', - 'third line', + ' third line', } end expect_whole_buffer(expected) @@ -148,7 +148,7 @@ local function test_insertion_to_an_existing_list() '- first item', '- new item', ' second line', - 'third line', + ' third line', '- third item', } end