-- 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, prefix_len = number } 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] -- Define patterns -- Pattern to capture the full TODO prefix, including trailing space(s) -- Captures: 1: Full prefix, 2: Indent, 3: Marker, 4: Checkbox state local todo_prefix_pattern = "^(%s*([%-%*%+])%s+%[([ x])%]%s*)" -- Pattern to capture a regular list prefix -- Captures: 1: Full prefix, 2: Indent, 3: Marker local list_prefix_pattern = "^(%s*([%-%*%+])%s+)" local indent = "" local marker = nil local content = "" local is_todo = false local prefix_len = 0 -- Try matching the TODO prefix first local todo_prefix, todo_indent, todo_marker, _ = string.match(line, todo_prefix_pattern) if todo_prefix then -- It's a TODO line is_todo = true indent = todo_indent marker = todo_marker prefix_len = #todo_prefix content = string.sub(line, prefix_len + 1) -- Content is everything after the matched prefix else -- Not a TODO line, try matching a regular list prefix local list_prefix, list_indent, list_marker = string.match(line, list_prefix_pattern) if list_prefix then -- It's a regular list line is_todo = false indent = list_indent marker = list_marker prefix_len = #list_prefix content = string.sub(line, prefix_len + 1) -- Content is everything after the matched prefix else -- Not a list line either, treat as plain text is_todo = false indent = string.match(line, "^%s*") or "" marker = nil prefix_len = #indent content = string.sub(line, prefix_len + 1) -- Use sub, trim might be too aggressive -- content = vim.trim(line) -- Old way end end return { line = line, indent = indent, marker = marker, -- '-', '*', '+', or nil content = content, is_todo = is_todo, prefix_len = prefix_len, -- Store the calculated prefix length } end -- Helper: Creates proper indent string based on buffer settings local function get_indent_string() local sw = vim.bo.shiftwidth local et = vim.bo.expandtab return et and string.rep(" ", sw) or "\t" end -- Debug helper to print line details local function debug_line(msg, details) print(string.format( "[DEBUG] %s:\n indent: '%s' (len=%d)\n marker: '%s'\n content: '%s'\n is_todo: %s", msg, details.indent, #details.indent, details.marker or "nil", details.content, details.is_todo )) end -- Helper: Gets details for the current line including cursor position local function get_current_line_details_with_cursor() local cursor_pos = vim.api.nvim_win_get_cursor(0) local lnum = cursor_pos[1] -- 1-based line number local col = cursor_pos[2] -- 0-based byte column return get_line_details(lnum), lnum, col end -- ti: Convert line to TODO, cursor after "] " (Normal mode) local function add_todo_insert_mode() local details, lnum = get_current_line_details_with_cursor() -- Use basic helper is fine -- 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 -- Use the raw content calculated by the improved get_line_details local text_content = details.content 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_with_cursor() -- Use basic helper is fine 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_with_cursor() -- Use basic helper is fine 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 -- td: Remove TODO markup from the current line (Normal mode) local function remove_todo_markup() local details, lnum, original_col = get_current_line_details_with_cursor() if not details or not details.is_todo then return end -- Only act on TODO lines -- Construct the new line without the TODO markup "[ ] " or "[x] " -- Ensure marker exists, default to '-' if somehow nil (shouldn't happen if is_todo) local use_marker = details.marker or "-" local new_line = details.indent .. use_marker .. " " .. details.content -- Calculate the position where the markup started -- This is the length of the indent + marker + one space local markup_start_col = #(details.indent .. use_marker .. " ") -- The markup itself is 4 characters: '[', ' ' or 'x', ']', ' ' local markup_len = 4 -- Update the line content vim.api.nvim_buf_set_lines(0, lnum - 1, lnum, false, { new_line }) -- Calculate the new cursor column local new_col if original_col < markup_start_col then -- Cursor was before the markup, keep its position new_col = original_col elseif original_col >= markup_start_col and original_col < markup_start_col + markup_len then -- Cursor was inside the markup, move it to the start of where the markup was new_col = markup_start_col else -- Cursor was after the markup, shift it left by the length of the removed markup new_col = original_col - markup_len end -- Ensure the column is not negative (shouldn't happen here, but good practice) new_col = math.max(0, new_col) -- Set the new cursor position vim.api.nvim_win_set_cursor(0, { lnum, new_col }) 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_with_cursor() -- Use basic helper is fine 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 }) -- Tell Neovim to ignore the original press vim.api.nvim_input('') return -- Action handled 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 }) -- Tell Neovim to ignore the original press vim.api.nvim_input('') return -- Action handled 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 }) -- Tell Neovim to ignore the original press vim.api.nvim_input('') return -- Action handled 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 call nvim_input(''). return -- Explicitly return nothing to be clear end end -- Handle tab indentation for TODO lines local function press_tab() local lnum = vim.api.nvim_win_get_cursor(0)[1] local details = get_line_details(lnum) print("\n[DEBUG TAB] Line "..lnum.." before:") debug_line("Original", details) if not details or not details.is_todo then print("[DEBUG TAB] Not a TODO line, using default Tab") return vim.api.nvim_replace_termcodes("", true, false, true) end local indent_str = get_indent_string() local new_line = details.indent .. indent_str .. (details.marker or "-") .. " [ ] " .. details.content print("[DEBUG TAB] New line will be:", new_line) print("[DEBUG TAB] Indent string:", "'"..indent_str.."' (length="..#indent_str..")") vim.api.nvim_set_current_line(new_line) -- Calculate new cursor column (preserve relative position) local cursor_col = vim.api.nvim_win_get_cursor(0)[2] local new_col = cursor_col + #indent_str vim.api.nvim_win_set_cursor(0, {vim.api.nvim_win_get_cursor(0)[1], new_col}) return "" -- Prevent default behavior end -- Handle shift-tab outdentation for TODO lines local function press_shift_tab() local lnum = vim.api.nvim_win_get_cursor(0)[1] local details = get_line_details(lnum) print("\n[DEBUG SHIFT-TAB] Line "..lnum.." before:") debug_line("Original", details) if not details or not details.is_todo or #details.indent == 0 then print("[DEBUG SHIFT-TAB] Not a TODO line or at min indent, using default S-Tab") return vim.api.nvim_replace_termcodes("", true, false, true) end local sw = vim.bo.shiftwidth local new_indent = details.indent:sub(1, -sw - 1) local new_line = new_indent .. (details.marker or "-") .. " [ ] " .. details.content print("[DEBUG SHIFT-TAB] New indent:", "'"..new_indent.."' (length="..#new_indent..")") print("[DEBUG SHIFT-TAB] New line will be:", new_line) vim.api.nvim_set_current_line(new_line) -- Calculate new cursor column (preserve relative position) local cursor_col = vim.api.nvim_win_get_cursor(0)[2] local new_col = math.max(0, cursor_col - sw) vim.api.nvim_win_set_cursor(0, {vim.api.nvim_win_get_cursor(0)[1], new_col}) return "" -- Prevent default behavior 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 ']') -- Adjusted pattern slightly to ensure it captures # correctly too if needed 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 -- Keep mapping without expr = true vim.keymap.set("i", "", press_enter, { desc = "Todoer: Handle Enter", buffer = 0 }) -- Tab handling for TODO items vim.keymap.set("i", "", press_tab, { desc = "Todoer: Handle Tab", buffer = 0 }) vim.keymap.set("i", "", press_shift_tab, { desc = "Todoer: Handle Shift-Tab", 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 }) -- Mapping for removing TODO markup vim.keymap.set("n", "td", remove_todo_markup, { desc = "Todoer: Remove TODO markup", 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