-- Create the module table local M = {} -- Default configuration local defaults = { filetypes = { "markdown", "text", "norg" }, -- Filetypes to activate the plugin for } -- Helper: Parses line details for a given line number (1-based) -- Returns table: { line = str, indent = str, marker = str|nil, content = str, is_todo = bool } or nil local function get_line_details(lnum) -- Use nvim_buf_get_lines which is 0-indexed for ranges local lines = vim.api.nvim_buf_get_lines(0, lnum - 1, lnum, false) if not lines or #lines == 0 then return nil end -- Handle potential errors getting line local line = lines[1] local indent = string.match(line, "^%s*") or "" -- Pattern captures marker '-', '*', or '+' followed by at least one space -- Returns: indent, marker, space_after_marker local marker_pattern = "^(%s*)([%-%*%+])(%s+)" -- Pattern captures marker, checkbox '[ ]' or '[x]', and optional space after checkbox -- Matches lines like: "- [ ] task", "* [x] task", "+ [ ] task" local todo_pattern = "^(%s*[%-%*%+]%s+)%[([ x])%]%s*" -- Capture prefix before checkbox local _, marker_match, _ = string.match(line, marker_pattern) local is_todo = (string.match(line, todo_pattern) ~= nil) local content = "" if is_todo then -- Content is everything after "[x] " or "[ ] " (including leading space) content = string.match(line, todo_pattern .. "(.*)") or "" elseif marker_match then -- Content is everything after "- " / "* " / "+ " content = string.match(line, marker_pattern .. "(.*)") or "" else -- No list marker, just the trimmed line content content = vim.trim(line) end return { line = line, indent = indent, marker = marker_match, -- '-', '*', '+', or nil content = content, is_todo = is_todo, } end -- Helper: Gets details for the current line local function get_current_line_details() local cursor_pos = vim.api.nvim_win_get_cursor(0) local lnum = cursor_pos[1] -- 1-based line number return get_line_details(lnum), lnum end -- ti: Convert line to TODO, cursor after "] " (Normal mode) local function add_todo_insert_mode() local details, lnum = get_current_line_details() -- Don't convert if already a TODO or if getting details failed if not details or details.is_todo then return end local use_marker = details.marker or "-" -- Default to '-' if no list marker found local text_content = details.content -- Content already extracted correctly by get_line_details local new_line = details.indent .. use_marker .. " [ ] " .. text_content -- Replace current line (lnum is 1-based, set_lines is 0-based) vim.api.nvim_buf_set_lines(0, lnum - 1, lnum, false, { new_line }) -- Set cursor position: 1-based row, 0-based byte column -- Position cursor right after the "] " local cursor_col_bytes = #(details.indent .. use_marker .. " [ ] ") -- Use Lua # operator vim.api.nvim_win_set_cursor(0, { lnum, cursor_col_bytes }) end -- ta: Convert line to TODO, cursor at end of line (Normal mode) local function add_todo_append_mode() local details, lnum = get_current_line_details() if not details or details.is_todo then return end local use_marker = details.marker or "-" local text_content = details.content local new_line = details.indent .. use_marker .. " [ ] " .. text_content vim.api.nvim_buf_set_lines(0, lnum - 1, lnum, false, { new_line }) -- Set cursor position: 1-based row, 0-based byte column -- Position cursor at the very end of the line content local cursor_col_bytes = #new_line -- Use Lua # operator vim.api.nvim_win_set_cursor(0, { lnum, cursor_col_bytes }) end -- to: Insert new TODO line below, cursor after "] " (Normal mode) local function add_todo_new_line_mode() local details, lnum = get_current_line_details() if not details then return end -- Should not happen usually, but check anyway -- Determine indent and marker based on current line, default to '-' if no marker local new_indent = details.indent local new_marker = details.marker or "-" local new_line_content = new_indent .. new_marker .. " [ ] " -- Insert after current line (lnum is 1-based, set_lines is 0-based for ranges) vim.api.nvim_buf_set_lines(0, lnum, lnum, false, { new_line_content }) -- Set cursor position: 1-based row (lnum + 1), 0-based byte column -- Position cursor at the end of the new line (after "] ") local cursor_col_bytes = #new_line_content -- Use Lua # operator vim.api.nvim_win_set_cursor(0, { lnum + 1, cursor_col_bytes }) end -- Internal function to set up buffer-local keymaps local function setup_buffer_keymaps() -- Handles Enter key press in Insert mode local function press_enter() local details, lnum = get_current_line_details() if not details then -- Getting details failed, do nothing and let Neovim handle Enter. return end -- Check if the line is a TODO item and if it has content after the marker -- Use string.match with %S to check for any non-whitespace character local has_content = details.content and string.match(details.content, "%S") if details.is_todo and has_content then -- Case 1: Non-empty TODO line -- Create a new TODO line below with same indent/marker local new_marker = details.marker or "-" -- Should always have marker if is_todo, but safety check local new_line_content = details.indent .. new_marker .. " [ ] " -- Insert the new line below the current one vim.api.nvim_buf_set_lines(0, lnum, lnum, false, { new_line_content }) -- Move cursor to the new line, after the "] " local cursor_col_bytes = #new_line_content vim.api.nvim_win_set_cursor(0, { lnum + 1, cursor_col_bytes }) -- Action handled, do not fall through to default behavior. -- No return value needed as it's not an expr mapping anymore. elseif details.is_todo and not has_content then -- Case 2: Empty TODO line (e.g., "- [ ]") - Implement Outdenting local current_indent = details.indent local current_indent_len = #current_indent local sw = vim.bo.shiftwidth -- Get buffer's shiftwidth local et = vim.bo.expandtab -- Get buffer's expandtab setting if current_indent_len > 0 then -- Line is indented, calculate new indentation local new_indent = "" if et then -- Using spaces local target_indent_len = math.max(0, current_indent_len - sw) new_indent = string.rep(" ", target_indent_len) else -- Using tabs -- Remove the first tab character found new_indent = string.gsub(current_indent, "^\t", "", 1) -- Safety check: if gsub didn't change anything (e.g., indent was spaces), -- try removing spaces as a fallback, though this is less ideal with noexpandtab if new_indent == current_indent and current_indent_len > 0 then local target_indent_len = math.max(0, current_indent_len - sw) new_indent = string.rep(" ", target_indent_len) end end -- Replace the current line with the outdented version local new_marker = details.marker or "-" local new_line_content = new_indent .. new_marker .. " [ ] " vim.api.nvim_buf_set_lines(0, lnum - 1, lnum, false, { new_line_content }) -- Move cursor after the "] " on the modified line local cursor_col_bytes = #new_line_content vim.api.nvim_win_set_cursor(0, { lnum, cursor_col_bytes }) -- Action handled, do not fall through. else -- Line is not indented, terminate the list -- Replace the line with an empty string vim.api.nvim_buf_set_lines(0, lnum - 1, lnum, false, { "" }) -- Now, manually insert a newline below using the API vim.api.nvim_buf_set_lines(0, lnum, lnum, false, { "" }) -- Move cursor to the new empty line vim.api.nvim_win_set_cursor(0, { lnum + 1, 0 }) -- Action handled, do not fall through. end else -- Case 3: Not a TODO line -- Do nothing. Neovim will automatically handle the Enter key press -- because this function didn't modify the buffer or explicitly handle it. return -- Explicitly return nothing to be clear end end -- indent line if tab is pressed when line is a todo (Will be refactored in Step 4) local function press_tab() local current_line = vim.api.nvim_get_current_line() -- Check if current line matches the patterns (Only checks '-' marker currently) -- TODO: Update pattern to match *, + as well local pattern = "^%s*%- %[[ x]%]" if string.match(current_line, pattern) then -- print("tab pressed, allegedly") -- Removed print vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "i", false) return "" -- Action handled by feedkeys else -- Return to allow default behavior return vim.api.nvim_replace_termcodes("", true, false, true) end end -- indent line if shift tab is pressed when line is a todo (Will be refactored in Step 4) local function press_shift_tab() local current_line = vim.api.nvim_get_current_line() -- Check if current line matches the patterns (Only checks '-' marker currently) -- TODO: Update pattern to match *, + as well local pattern = "^%s*%- %[[ x]%]" if string.match(current_line, pattern) then -- print("shift tab pressed, allegedly") -- Removed print vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "i", false) return "" -- Action handled by feedkeys else -- Return to allow default behavior return vim.api.nvim_replace_termcodes("", true, false, true) end end -- Toggles the checkbox [ ] <-> [x] for lines starting with -, *, +, or # local function toggle_todo() local line = vim.api.nvim_get_current_line() -- Pattern captures: -- 1: Prefix (indentation, marker '-', '*', '+', or '#', and whitespace) -- 2: State (' ' or 'x') -- 3: Rest of the line (after ']') local prefix, state, rest = string.match(line, "^([%s]*[%-#%*%+]%s*)%[([ x])%](.*)") if prefix then -- Check if the pattern matched (i.e., it's a toggleable line) -- Determine the new state local new_state = (state == ' ') and 'x' or ' ' -- Reconstruct the line with the new state local new_line = prefix .. "[" .. new_state .. "]" .. rest -- Update the current line vim.api.nvim_set_current_line(new_line) end -- If the pattern didn't match, do nothing. end -- Set up buffer-local keymaps -- Use buffer = 0 to target the current buffer vim.keymap.set("n", "t", function() end, { desc = "+TODOs", buffer = 0 }) -- Placeholder -- Removed expr = true from mapping vim.keymap.set("i", "", press_enter, { desc = "Todoer: Handle Enter", buffer = 0 }) -- Keep expr = true for Tab/S-Tab for now, until they are refactored vim.keymap.set("i", "", press_tab, { desc = "Todoer: Handle Tab", expr = true, buffer = 0 }) vim.keymap.set("i", "", press_shift_tab, { desc = "Todoer: Handle Shift-Tab", expr = true, buffer = 0 }) vim.keymap.set("n", "tt", toggle_todo, { desc = "Todoer: Toggle TODO", buffer = 0 }) -- New mappings for adding TODOs vim.keymap.set("n", "ti", add_todo_insert_mode, { desc = "Todoer: Add TODO (cursor after marker)", buffer = 0, silent = true }) vim.keymap.set("n", "ta", add_todo_append_mode, { desc = "Todoer: Add TODO (cursor at end)", buffer = 0, silent = true }) vim.keymap.set("n", "to", add_todo_new_line_mode, { desc = "Todoer: Add new TODO line below", buffer = 0, silent = true }) -- Optional: Notify that keymaps are set for this buffer -- vim.notify("Todoer keymaps activated for this buffer", vim.log.levels.INFO) end -- Setup function: Called by the user in their config function M.setup(opts) -- Merge user options with defaults local config = vim.tbl_deep_extend("force", {}, defaults, opts or {}) -- Create an autocommand group to ensure we can clear it later if needed local group = vim.api.nvim_create_augroup("TodoerUserSetup", { clear = true }) -- Create the autocommand vim.api.nvim_create_autocmd("FileType", { group = group, pattern = config.filetypes, -- Use filetypes from config desc = "Setup Todoer keymaps for specific filetypes", callback = function() -- Call the function that sets up buffer-local keymaps setup_buffer_keymaps() end, }) end -- Return the module table return M