Skip to content

Commit

Permalink
fix: improve block and list indentations (nvim-orgmode#629)
Browse files Browse the repository at this point in the history
  • Loading branch information
PriceHiller authored Dec 4, 2023
1 parent cbb10d4 commit 92bfc3f
Show file tree
Hide file tree
Showing 2 changed files with 214 additions and 87 deletions.
232 changes: 145 additions & 87 deletions lua/orgmode/org/indent.lua
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -47,27 +128,81 @@ 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
matches[i + 1] = opts
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
Expand Down Expand Up @@ -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()
Expand Down
69 changes: 69 additions & 0 deletions tests/plenary/org/indent_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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]])
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit 92bfc3f

Please sign in to comment.