diff --git a/lua/orgmode/org/indent.lua b/lua/orgmode/org/indent.lua index 0a088ad5f..ee493f114 100644 --- a/lua/orgmode/org/indent.lua +++ b/lua/orgmode/org/indent.lua @@ -1,7 +1,88 @@ local config = require('orgmode.config') +local headline_lib = require('orgmode.treesitter.headline') local ts_utils = require('nvim-treesitter.ts_utils') local query = nil +local function get_indent_pad(linenr) + local indent_mode = config.org_indent_mode == 'indent' + if indent_mode then + local headline = headline_lib.from_cursor({ linenr, 0 }) + if not headline then + return 0 + end + return headline:level() + 1 + end + return 0 +end + +local function get_indent_for_match(matches, linenr, mode) + linenr = linenr or vim.v.lnum + mode = mode or vim.fn.mode() + local prev_linenr = vim.fn.prevnonblank(linenr - 1) + local match = matches[linenr] + local prev_line_match = matches[prev_linenr] + local indent = 0 + + if not match and not prev_line_match then + return indent + get_indent_pad(linenr) + end + + match = match or {} + prev_line_match = prev_line_match or {} + + if match.type == 'headline' then + -- We ensure we check headlines (even if a bit redundant) to ensure nothing else is checked below + return 0 + end + 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 = nil + 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. We recursively find the correct indent for this + -- based on its parents correct indentation level. + first_line_indent = vim.fn.indent(parent_linenr) + parent_match.overhang + end + end + -- If the first_line_indent wasn't found then this is the root of the list, as such we just pad accordingly + indent = first_line_indent or (0 + get_indent_pad(linenr)) + -- If the current line is hanging content as part of the listitem but not on the same line we want to indent it + -- such that it's in line with the general content body, not the bullet. + -- + -- - I am the "first" line listitem + -- I am the content body as part of the listitem, but on a different line! + if linenr ~= match.line_nr then + indent = indent + match.overhang + end + return indent + end + if mode:match('^[iR]') and prev_line_match.type == 'listitem' and linenr - prev_linenr < 3 then + -- 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. + -- + -- 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. + indent = get_indent_for_match(matches, prev_linenr) + if prev_linenr == prev_line_match.line_nr then + indent = indent + prev_line_match.overhang + end + return indent + end + if match.indent_type == 'block' then + -- Blocks do some precalculation of their own against the intended indent level of the parent. As such we just want + -- to return their indent without any other modifications. + return match.indent + end + + return indent + get_indent_pad(linenr) +end + local get_matches = ts_utils.memoize_by_buf_tick(function(bufnr) local tree = vim.treesitter.get_parser(bufnr, 'org', {}):parse() if not tree or not #tree then @@ -47,6 +128,8 @@ local get_matches = ts_utils.memoize_by_buf_tick(function(bufnr) while parent and parent:type() ~= 'section' and parent:type() ~= 'listitem' do parent = parent:parent() end + local prev_sibling = node:prev_sibling() + opts.prev_sibling_linenr = prev_sibling and (prev_sibling:start() + 1) opts.nesting_parent_linenr = parent and (parent:start() + 1) for i = range.start.line, range['end'].line - 1 do @@ -54,20 +137,72 @@ local get_matches = ts_utils.memoize_by_buf_tick(function(bufnr) end end - if type == 'paragraph' or type == 'drawer' or type == 'property_drawer' or type == 'block' then - opts.indent_type = 'other' + if type == 'block' then + opts.indent_type = 'block' local parent = node:parent() - while parent and parent:type() ~= 'section' do + while parent and parent:type() ~= 'section' and parent:type() ~= 'listitem' do parent = parent:parent() end - if parent then - local headline = parent:named_child('headline') - local stars = vim.treesitter.get_node_text(headline:field('stars')[1], bufnr):len() - opts.indent = stars + 1 - for i = range.start.line, range['end'].line - 1 do - matches[i + 1] = opts + -- We want to find the difference in indentation level between the item to be indented and the parent node. + -- If the item is in the block, we shouldn't change the indentation beyond how much we modify the indent of the + -- block header and footer. This keeps code correctly indented in `BEGIN_SRC` blocks as well as ensuring + -- `BEGIN_EXAMPLE` blocks don't have their indentation changed inside of them. + local parent_linenr = parent:start() + 1 + local parent_indent = get_indent_for_match(matches, parent:start() + 1) + + -- We want to align to the listitem body, not the bullet + if parent:type() == 'listitem' then + parent_indent = parent_indent + matches[parent_linenr].overhang + else + parent_indent = get_indent_pad(range.start.line + 1) + end + + local curr_header_indent = vim.fn.indent(range.start.line + 1) + local header_indent_diff = curr_header_indent - parent_indent + local new_header_indent = curr_header_indent - header_indent_diff + -- Ensure the block footer is properly aligned with the header + matches[range.start.line + 1] = vim.tbl_deep_extend('force', opts, { + indent = new_header_indent, + }) + matches[range['end'].line] = vim.tbl_deep_extend('force', opts, { + indent = new_header_indent, + }) + + local content_indent_pad = 0 + -- Only include the header line and the content. Do not include the footer in the loop. + for i = range.start.line + 1, range['end'].line - 2 do + local curr_indent = vim.fn.indent(i + 1) + -- Correctly align the pad to the new header position if it was underindented + local new_indent_pad = new_header_indent - curr_indent + -- If the current content indentaion is less than the new header indent we want to increase all of the + -- content by the largest difference in indentation between a given content line and the new header indent. + if curr_indent < new_header_indent then + content_indent_pad = math.max(new_indent_pad, content_indent_pad) + else + -- If the current content indentation is more than the new header indentation, but it was the current + -- content indentation was less than the current header indent then we want to add some indentation onto + -- the content by the largest negative difference (meaning -1 > -2 > -3 so take -1 as the pad). + -- + -- 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 + -- such if the current pad is 0 just set to the new calculated pad. + if content_indent_pad == 0 then + content_indent_pad = new_indent_pad + else + content_indent_pad = math.max(new_indent_pad, content_indent_pad) + end end end + -- If any of the content is underindented relative to the header and footer, we need to indent all of the + -- content until the most underindented content is equal in indention to the header and footer. + -- + -- Only loop the content. + for i = range.start.line + 1, range['end'].line - 2 do + matches[i + 1] = vim.tbl_deep_extend('force', opts, { + indent = vim.fn.indent(i + 1) + content_indent_pad, + }) + end + elseif type == 'paragraph' or type == 'drawer' or type == 'property_drawer' then + opts.indent_type = 'other' end end end @@ -119,87 +254,10 @@ end 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(linenr - 1) - local matches = get_matches(0) - local match = matches[linenr] - local prev_line_match = matches[prev_linenr] - - if not match and not prev_line_match then - return -1 - end - - match = match or {} - prev_line_match = prev_line_match or {} - - if prev_line_match.type == 'headline' then - if noindent_mode or (match.type == 'headline' and match.stars > 0) then - return 0 - end - return prev_line_match.indent - end - - if match.type == 'headline' then - return 0 - end - - 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 - -- 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 - - -- 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 - return 0 - end - - if match.indent_type == 'other' then - return match.indent - end - return vim.fn.indent(prev_linenr) + return get_indent_for_match(matches, linenr, mode) end local function foldtext() diff --git a/tests/plenary/org/indent_spec.lua b/tests/plenary/org/indent_spec.lua index 13f5e004d..3c05bf3e9 100644 --- a/tests/plenary/org/indent_spec.lua +++ b/tests/plenary/org/indent_spec.lua @@ -40,6 +40,29 @@ local function test_full_reindent() ' continuation', ' part of the first-level list', 'Not part of the list', + '', + '*** Incorrectly indented block', + ' #+BEGIN_SRC json', + ' {', + ' "key": "value",', + ' "another key": "another value"', + ' }', + ' #+END_SRC', + '', + ' - Correctly reindents to list indentation level', + ' #+BEGIN_SRC json', + ' {', + ' "key": "value",', + ' "another key": "another value"', + ' }', + '#+END_SRC', + ' - Correctly reindents when entire block overindented', + ' #+BEGIN_SRC json', + ' {', + ' "key": "value",', + ' "another key": "another value"', + ' }', + ' #+END_SRC', } helpers.load_file_content(unformatted_file) vim.cmd([[silent norm 0gg=G]]) @@ -73,6 +96,29 @@ local function test_full_reindent() ' continuation', ' part of the first-level list', ' Not part of the list', + '', + '*** Incorrectly indented block', + ' #+BEGIN_SRC json', + ' {', + ' "key": "value",', + ' "another key": "another value"', + ' }', + ' #+END_SRC', + '', + ' - Correctly reindents to list indentation level', + ' #+BEGIN_SRC json', + ' {', + ' "key": "value",', + ' "another key": "another value"', + ' }', + ' #+END_SRC', + ' - Correctly reindents when entire block overindented', + ' #+BEGIN_SRC json', + ' {', + ' "key": "value",', + ' "another key": "another value"', + ' }', + ' #+END_SRC', } elseif config.org_indent_mode == 'noindent' then expected = { @@ -103,6 +149,29 @@ local function test_full_reindent() ' continuation', ' part of the first-level list', 'Not part of the list', + '', + '*** Incorrectly indented block', + '#+BEGIN_SRC json', + '{', + ' "key": "value",', + ' "another key": "another value"', + '}', + '#+END_SRC', + '', + '- Correctly reindents to list indentation level', + ' #+BEGIN_SRC json', + ' {', + ' "key": "value",', + ' "another key": "another value"', + ' }', + ' #+END_SRC', + '- Correctly reindents when entire block overindented', + ' #+BEGIN_SRC json', + ' {', + ' "key": "value",', + ' "another key": "another value"', + ' }', + ' #+END_SRC', } end expect_whole_buffer(expected)