From 2de9c3af91e64c497d1a87569e47f36219cc1569 Mon Sep 17 00:00:00 2001 From: Dorian Karter Date: Wed, 3 Jun 2026 11:43:55 -0500 Subject: [PATCH] feat: handle empty bullets and spacing --- lua/bullets/actions.lua | 138 ++++++++++++++++++++++++++++++++++++++-- test/bullets_spec.lua | 86 +++++++++++++++++++++++-- 2 files changed, 214 insertions(+), 10 deletions(-) diff --git a/lua/bullets/actions.lua b/lua/bullets/actions.lua index 99f1366..d16e71a 100644 --- a/lua/bullets/actions.lua +++ b/lua/bullets/actions.lua @@ -191,9 +191,10 @@ local function at_eol(line) return vim.fn.col(".") == #line + 1 end -local function insert_line(lnum, line) - vim.api.nvim_buf_set_lines(0, lnum, lnum, false, { line }) - vim.api.nvim_win_set_cursor(0, { lnum + 1, #line }) +local function insert_lines(lnum, lines, cursor_index) + vim.api.nvim_buf_set_lines(0, lnum, lnum, false, lines) + local line = lines[cursor_index] + vim.api.nvim_win_set_cursor(0, { lnum + cursor_index, #line }) end local function pad_right(prefix, width) @@ -248,6 +249,120 @@ local function next_prefix(bullet) return bullet.indent .. pad_right(prefix, #bullet.marker + #bullet.closure + #bullet.spacing) end +local function current_prefix(bullet) + if bullet.type == "std" or bullet.type == "static" then + return bullet.indent .. bullet.marker .. bullet.spacing + end + + local prefix = bullet.marker .. bullet.closure .. " " + return bullet.indent .. pad_right(prefix, #bullet.marker + #bullet.closure + #bullet.spacing) +end + +local function indent_unit() + if not vim.o.expandtab then + return "\t" + end + + return string.rep(" ", vim.o.shiftwidth > 0 and vim.o.shiftwidth or vim.o.tabstop) +end + +local function style_for_bullet(bullet) + if bullet.type == "std" then + return "std" .. bullet.marker + end + if bullet.type == "static" then + return bullet.marker + end + if bullet.type == "num" then + return "num" + end + if bullet.type == "abc" then + return bullet.marker == bullet.marker:lower() and "abc" or "ABC" + end + if bullet.type == "rom" then + return bullet.marker == bullet.marker:lower() and "rom" or "ROM" + end + + return nil +end + +local function bullet_for_style(style, indent) + if style == "num" then + return { type = "num", indent = indent, marker = "1", closure = ".", spacing = " ", text = "" } + end + if style == "abc" then + return { type = "abc", indent = indent, marker = "a", closure = ".", spacing = " ", text = "" } + end + if style == "ABC" then + return { type = "abc", indent = indent, marker = "A", closure = ".", spacing = " ", text = "" } + end + if style == "rom" then + return { type = "rom", indent = indent, marker = "i", closure = ".", spacing = " ", text = "" } + end + if style == "ROM" then + return { type = "rom", indent = indent, marker = "I", closure = ".", spacing = " ", text = "" } + end + + local marker = style:match("^std(.+)$") + if marker then + return { type = "std", indent = indent, marker = marker, spacing = " ", text = "" } + end + + return nil +end + +local function child_bullet(bullet) + local current_style = style_for_bullet(bullet) + if not current_style then + return nil + end + + for index, style in ipairs(config.options.outline_levels) do + if style == current_style then + local next_style = config.options.outline_levels[index + 1] + return next_style and bullet_for_style(next_style, bullet.indent .. indent_unit()) or nil + end + end + + return nil +end + +local function ends_with_colon(text) + return text:sub(-1) == ":" or text:sub(-3) == ":" +end + +local function spaced_lines(prefix) + local lines = {} + for _ = 2, config.options.line_spacing do + table.insert(lines, "") + end + table.insert(lines, prefix) + return lines, #lines +end + +local function delete_empty_bullet(lnum, bullet) + local behavior = config.options.delete_last_bullet_if_empty + if behavior == 0 then + feed("") + return true + end + + if behavior == 2 and bullet.indent ~= "" then + local promoted = vim.deepcopy(bullet) + promoted.indent = promoted.indent + :gsub("\t$", "") + :gsub(string.rep(" ", vim.o.shiftwidth > 0 and vim.o.shiftwidth or vim.o.tabstop) .. "$", "") + local prefix = next_prefix(promoted) + vim.api.nvim_set_current_line(prefix or "") + vim.api.nvim_win_set_cursor(0, { lnum, #(prefix or "") }) + return true + end + + vim.api.nvim_set_current_line(bullet.indent) + vim.api.nvim_win_set_cursor(0, { lnum, #bullet.indent }) + return true +end + local function wrapped_owner(lnum, line) if not config.options.enable_wrapped_lines or line:match("^%s*$") then return nil @@ -290,13 +405,26 @@ function M.insert_new_bullet() return "" end - local prefix = next_prefix(bullet) + if bullet.text == "" and delete_empty_bullet(lnum, bullet) then + return "" + end + + local prefix + if config.options.auto_indent_after_colon and ends_with_colon(bullet.text) then + local child = child_bullet(bullet) + if child then + prefix = current_prefix(child) + end + end + + prefix = prefix or next_prefix(bullet) if not prefix then feed("") return "" end - insert_line(lnum, prefix) + local lines, cursor_index = spaced_lines(prefix) + insert_lines(lnum, lines, cursor_index) if mode == "n" then vim.cmd.startinsert({ bang = true }) diff --git a/test/bullets_spec.lua b/test/bullets_spec.lua index ba1be35..f9085c0 100644 --- a/test/bullets_spec.lua +++ b/test/bullets_spec.lua @@ -217,7 +217,8 @@ describe("Bullets.vim", function() }, helpers.get_lines()) end) - it("deletes the last bullet if it is empty", function() + active_it("deletes the last bullet if it is empty", function() + require("bullets").setup({ delete_last_bullet_if_empty = 1 }) helpers.new_buffer({ "# Hello there", "- this is the first bullet", @@ -235,8 +236,8 @@ describe("Bullets.vim", function() }, lines) end) - it("promote the last bullet when configured to", function() - vim.g.bullets_delete_last_bullet_if_empty = 2 + active_it("promotes the last bullet when configured to", function() + require("bullets").setup({ delete_last_bullet_if_empty = 2 }) helpers.new_buffer({ "# Hello there", "- this is the first bullet", @@ -258,8 +259,8 @@ describe("Bullets.vim", function() }, lines) end) - it("does not delete the last bullet when configured not to", function() - vim.g.bullets_delete_last_bullet_if_empty = 0 + active_it("does not delete the last bullet when configured not to", function() + require("bullets").setup({ delete_last_bullet_if_empty = 0 }) helpers.new_buffer({ "# Hello there", "- this is the first bullet", @@ -280,6 +281,81 @@ describe("Bullets.vim", function() }, lines) end) + active_it("adds configured blank spacing before the next bullet", function() + require("bullets").setup({ line_spacing = 2 }) + helpers.test_bullet_inserted("second bullet", { + "# Hello there", + "1. first bullet", + }, { + "# Hello there", + "1. first bullet", + "", + "2. second bullet", + }) + end) + + active_it("can disable right padding for ordered bullets", function() + require("bullets").setup({ pad_right = false }) + helpers.test_bullet_inserted("second bullet", { + "# Hello there", + "9. first bullet", + }, { + "# Hello there", + "9. first bullet", + "10. second bullet", + }) + end) + + active_it("indents after a line ending in a colon", function() + require("bullets").setup({ auto_indent_after_colon = true }) + helpers.new_buffer({ + "# Hello there", + "a. first bullet", + }) + helpers.feedkeys("Asecond bullet:third bulletfourth bullet") + assert.are.same({ + "# Hello there", + "a. first bullet", + "b. second bullet:", + " i. third bullet", + " ii. fourth bullet", + }, helpers.get_lines()) + end) + + active_it("indents after a line ending in a fullwidth colon", function() + require("bullets").setup({ auto_indent_after_colon = true }) + helpers.new_buffer({ + "# Hello there", + "a. first bullet", + }) + helpers.feedkeys("Asecond bullet:third bullet") + assert.are.same({ + "# Hello there", + "a. first bullet", + "b. second bullet:", + " i. third bullet", + }, helpers.get_lines()) + end) + + active_it("can disable colon auto indentation", function() + require("bullets").setup({ auto_indent_after_colon = false }) + helpers.test_bullet_inserted("second bullet:", { + "# Hello there", + "a. first bullet", + }, { + "# Hello there", + "a. first bullet", + "b. second bullet:", + }) + helpers.feedkeys("Athird bullet") + assert.are.same({ + "# Hello there", + "a. first bullet", + "b. second bullet:", + "c. third bullet", + }, helpers.get_lines()) + end) + active_it("toggles roman numeral bullets with enable_roman_list", function() -- Disable alpha lists to isolate test to roman numerals require("bullets").setup({ max_alpha_characters = 0, enable_roman_list = true })