diff --git a/lua/bullets/actions.lua b/lua/bullets/actions.lua index cb75b0d..3ce3524 100644 --- a/lua/bullets/actions.lua +++ b/lua/bullets/actions.lua @@ -1,4 +1,6 @@ local M = {} +local config = require("bullets.config") +local ordinal = require("bullets.ordinal") local function feed(keys) vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(keys, true, false, true), "n", false) @@ -18,6 +20,127 @@ local function parse_standard(line) } end +local function parse_numeric(line) + local indent, marker, closure, spacing, text = line:match("^(%s*)(%d+)([.)])(%s+)(.*)$") + if not marker then + return nil + end + + return { + type = "num", + indent = indent, + marker = marker, + closure = closure, + spacing = spacing, + text = text, + } +end + +local function parse_alpha(line) + local max = config.options.max_alpha_characters + if max == 0 then + return nil + end + + local indent, marker, closure, spacing, text = line:match("^(%s*)(%a+)([.)])(%s+)(.*)$") + if not marker or #marker > max or not (marker == marker:lower() or marker == marker:upper()) then + return nil + end + + return { + type = "abc", + indent = indent, + marker = marker, + closure = closure, + spacing = spacing, + text = text, + } +end + +local function parse_roman(line) + if not config.options.enable_roman_list then + return nil + end + + local indent, marker, closure, spacing, text = line:match("^(%s*)(%a+)([.)])(%s+)(.*)$") + if not marker or not (marker == marker:lower() or marker == marker:upper()) or not ordinal.is_roman(marker) then + return nil + end + + return { + type = "rom", + indent = indent, + marker = marker, + closure = closure, + spacing = spacing, + text = text, + } +end + +local function parse_line(line) + local standard = parse_standard(line) + if standard then + standard.type = "std" + return { standard } + end + + local numeric = parse_numeric(line) + if numeric then + return { numeric } + end + + local alpha = parse_alpha(line) + local roman = parse_roman(line) + + return vim.tbl_filter(function(item) + return item ~= nil + end, { alpha, roman }) +end + +local resolve_bullet + +local function previous_ordered_type(lnum, indent) + for row = lnum - 1, 1, -1 do + local line = vim.api.nvim_buf_get_lines(0, row - 1, row, false)[1] + if line == "" then + return nil + end + + local bullet = resolve_bullet(parse_line(line), row) + if bullet and bullet.indent == indent and (bullet.type == "abc" or bullet.type == "rom") then + return bullet.type + end + end + + return nil +end + +function resolve_bullet(bullets, lnum) + if #bullets == 0 then + return nil + end + if #bullets == 1 then + return bullets[1] + end + + local previous_type = previous_ordered_type(lnum, bullets[1].indent) + if previous_type then + for _, bullet in ipairs(bullets) do + if bullet.type == previous_type then + return bullet + end + end + end + + for _, bullet in ipairs(bullets) do + if bullet.type == "rom" then + return bullet + end + end + + return bullets[1] +end + local function at_eol(line) return vim.fn.col(".") == #line + 1 end @@ -27,11 +150,52 @@ local function insert_line(lnum, line) vim.api.nvim_win_set_cursor(0, { lnum + 1, #line }) end +local function pad_right(prefix, width) + if not config.options.pad_right or #prefix >= width then + return prefix + end + + return prefix .. string.rep(" ", width - #prefix) +end + +local function next_marker(bullet) + if bullet.type == "num" then + return tostring(tonumber(bullet.marker) + 1) + end + if bullet.type == "abc" then + local marker = + ordinal.number_to_abc(ordinal.abc_to_number(bullet.marker) + 1, bullet.marker == bullet.marker:lower()) + if #marker > config.options.max_alpha_characters then + return nil + end + return marker + end + if bullet.type == "rom" then + return ordinal.number_to_roman(ordinal.roman_to_number(bullet.marker) + 1, bullet.marker == bullet.marker:lower()) + end + + return bullet.marker +end + +local function next_prefix(bullet) + local marker = next_marker(bullet) + if not marker then + return nil + end + + if bullet.type == "std" then + return bullet.indent .. marker .. bullet.spacing + end + + local prefix = marker .. bullet.closure .. " " + return bullet.indent .. pad_right(prefix, #bullet.marker + #bullet.closure + #bullet.spacing) +end + function M.insert_new_bullet() local mode = vim.fn.mode() local lnum = vim.api.nvim_win_get_cursor(0)[1] local line = vim.api.nvim_get_current_line() - local bullet = parse_standard(line) + local bullet = resolve_bullet(parse_line(line), lnum) if mode ~= "n" and not at_eol(line) then feed("") @@ -48,7 +212,13 @@ function M.insert_new_bullet() return "" end - insert_line(lnum, bullet.indent .. bullet.marker .. bullet.spacing) + local prefix = next_prefix(bullet) + if not prefix then + feed("") + return "" + end + + insert_line(lnum, prefix) if mode == "n" then vim.cmd.startinsert({ bang = true }) diff --git a/lua/bullets/ordinal.lua b/lua/bullets/ordinal.lua new file mode 100644 index 0000000..e0ffee2 --- /dev/null +++ b/lua/bullets/ordinal.lua @@ -0,0 +1,95 @@ +local M = {} + +local roman_values = { + { 1000, "m" }, + { 900, "cm" }, + { 500, "d" }, + { 400, "cd" }, + { 100, "c" }, + { 90, "xc" }, + { 50, "l" }, + { 40, "xl" }, + { 10, "x" }, + { 9, "ix" }, + { 5, "v" }, + { 4, "iv" }, + { 1, "i" }, +} + +function M.abc_to_number(value) + local result = 0 + local lower = value:lower() + + for i = 1, #lower do + result = result * 26 + lower:byte(i) - string.byte("a") + 1 + end + + return result +end + +function M.number_to_abc(value, lower) + local base = lower and string.byte("a") or string.byte("A") + local result = "" + + while value > 0 do + value = value - 1 + result = string.char(base + value % 26) .. result + value = math.floor(value / 26) + end + + return result +end + +function M.roman_to_number(value) + local roman = value:lower() + local result = 0 + local index = 1 + + while index <= #roman do + local matched = false + for _, pair in ipairs(roman_values) do + local number, letters = pair[1], pair[2] + if roman:sub(index, index + #letters - 1) == letters then + result = result + number + index = index + #letters + matched = true + break + end + end + + if not matched then + return nil + end + end + + return result +end + +function M.number_to_roman(value, lower) + local result = "" + + for _, pair in ipairs(roman_values) do + local number, letters = pair[1], pair[2] + while value >= number do + result = result .. letters + value = value - number + end + end + + if lower then + return result + end + + return result:upper() +end + +function M.is_roman(value) + local number = M.roman_to_number(value) + if not number then + return false + end + + return M.number_to_roman(number, value == value:lower()) == value +end + +return M diff --git a/test/alphabetic_bullets_spec.lua b/test/alphabetic_bullets_spec.lua index 9c6db10..7bfc5c0 100644 --- a/test/alphabetic_bullets_spec.lua +++ b/test/alphabetic_bullets_spec.lua @@ -1,4 +1,5 @@ local helpers = require("test.helpers") +local active_it = it local it = pending describe("Bullets.vim", function() @@ -7,7 +8,7 @@ describe("Bullets.vim", function() helpers.reset_config() end) - it("adds a new upper case bullet", function() + active_it("adds a new upper case bullet", function() helpers.new_buffer({ "# Hello there", "A. this is the first bullet", @@ -31,7 +32,7 @@ describe("Bullets.vim", function() }, helpers.get_lines()) end) - it("adds a new lower case bullet", function() + active_it("adds a new lower case bullet", function() helpers.new_buffer({ "# Hello there", "a. this is the first bullet", @@ -56,7 +57,7 @@ describe("Bullets.vim", function() end) it("adds a new bullet and loops at z", function() - vim.g.bullets_renumber_on_change = 0 + require("bullets").setup({ renumber_on_change = false }) helpers.new_buffer({ "# Hello there", "y. this is the first bullet", @@ -80,7 +81,7 @@ describe("Bullets.vim", function() }, helpers.get_lines()) end) - it("does not add a new bullet when mixed case", function() + active_it("does not add a new bullet when mixed case", function() -- "Ab." is mixed case so the plugin doesn't recognise it as a bullet. -- CR is therefore deferred via feedkeys('n'); the 'tx' flag in our outer -- feedkeys drains that deferred CR, leaving normal mode on a new empty line. @@ -99,8 +100,8 @@ describe("Bullets.vim", function() end) describe("g:bullets_max_alpha_characters", function() - it("stops adding items after configured max (default 2)", function() - vim.g.bullets_renumber_on_change = 0 + active_it("stops adding items after configured max (default 2)", function() + require("bullets").setup({ renumber_on_change = false }) helpers.new_buffer({ "# Hello there", "zy. this is the first bullet", @@ -120,8 +121,8 @@ describe("Bullets.vim", function() }, helpers.get_lines()) end) - it("does not bullets if configured as 0", function() - vim.g.bullets_max_alpha_characters = 0 + active_it("does not bullets if configured as 0", function() + require("bullets").setup({ max_alpha_characters = 0 }) helpers.new_buffer({ "# Hello there", "a. this is the first bullet", diff --git a/test/bullets_spec.lua b/test/bullets_spec.lua index d886ce5..3d93521 100644 --- a/test/bullets_spec.lua +++ b/test/bullets_spec.lua @@ -78,7 +78,7 @@ describe("Bullets.vim", function() ) end) - it("adds a new numeric bullet if the previous line had numeric bullet", function() + active_it("adds a new numeric bullet if the previous line had numeric bullet", function() helpers.test_bullet_inserted( "second bullet", { "# Hello there", "1) this is the first bullet" }, @@ -86,7 +86,7 @@ describe("Bullets.vim", function() ) end) - it("adds a new numeric bullet with right padding", function() + active_it("adds a new numeric bullet with right padding", function() helpers.test_bullet_inserted( "second bullet", { "# Hello there", "1. this is the first bullet" }, @@ -94,8 +94,8 @@ describe("Bullets.vim", function() ) end) - it("maintains total bullet width from 9. to 10. with reduced padding", function() - vim.g.bullets_renumber_on_change = 0 + active_it("maintains total bullet width from 9. to 10. with reduced padding", function() + require("bullets").setup({ renumber_on_change = false }) helpers.test_bullet_inserted( "second bullet", { "# Hello there", "9. this is the first bullet" }, @@ -111,7 +111,7 @@ describe("Bullets.vim", function() ) end) - it("does not insert a new numeric bullet for decimal numbers", function() + active_it("does not insert a new numeric bullet for decimal numbers", function() -- "3.14159 is an approximation of pi." is not a bullet line -- CR on non-bullet line is deferred, use two-call pattern helpers.new_buffer({ @@ -127,8 +127,8 @@ describe("Bullets.vim", function() }, helpers.get_lines()) end) - it("adds a new roman numeral bullet", function() - vim.g.bullets_pad_right = 0 + active_it("adds a new roman numeral bullet", function() + require("bullets").setup({ pad_right = false }) helpers.new_buffer({ "# Hello there", "I. this is the first bullet", @@ -144,8 +144,8 @@ describe("Bullets.vim", function() }, helpers.get_lines()) end) - it("adds a new lowercase roman numeral bullet", function() - vim.g.bullets_pad_right = 0 + active_it("adds a new lowercase roman numeral bullet", function() + require("bullets").setup({ pad_right = false }) helpers.new_buffer({ "# Hello there", "i. this is the first bullet", @@ -161,7 +161,7 @@ describe("Bullets.vim", function() }, helpers.get_lines()) end) - it("does not confuse with the 'ignorecase' option", function() + active_it("does not confuse with the 'ignorecase' option", function() vim.cmd("set ignorecase") -- "Vi." is mixed case / not a valid roman numeral bullet → non-bullet CR helpers.new_buffer({ @@ -177,7 +177,7 @@ describe("Bullets.vim", function() }, helpers.get_lines()) end) - it("does not insert a new roman bullets without following spaces", function() + active_it("does not insert a new roman bullets without following spaces", function() -- "m.example.com is a site." has no space after the dot → not a bullet helpers.new_buffer({ "# Hello there", @@ -192,7 +192,7 @@ describe("Bullets.vim", function() }, helpers.get_lines()) end) - it("does not insert a new roman bullets for invalid roman numbers", function() + active_it("does not insert a new roman bullets for invalid roman numbers", function() -- "LID." is not a valid roman numeral, so no bullet continuation -- However lines typed after non-bullet lines also get no bullet helpers.new_buffer({ @@ -280,10 +280,9 @@ describe("Bullets.vim", function() }, lines) end) - it("toggles roman numeral bullets with g:bullets_enable_roman_list", function() + active_it("toggles roman numeral bullets with enable_roman_list", function() -- Disable alpha lists to isolate test to roman numerals - vim.g.bullets_max_alpha_characters = 0 - vim.g.bullets_enable_roman_list = 1 + require("bullets").setup({ max_alpha_characters = 0, enable_roman_list = true }) helpers.new_buffer({ "# Hello there", "i. this is the first bullet", @@ -291,7 +290,7 @@ describe("Bullets.vim", function() -- Type second and third bullets (roman numeral bullets) helpers.feedkeys("Asecond bulletthird bullet") -- Disable roman list mid-test - vim.g.bullets_enable_roman_list = 0 + require("bullets").setup({ max_alpha_characters = 0, enable_roman_list = false }) -- Type fourth and fifth (no roman numeral prefix now) -- We're in normal mode, need to append and continue helpers.feedkeys("A")