--- JQuote: A Neovim plugin for cycling through quote characters --- @module jquote --- @author Jan --- @license MIT local M = {} --- Plugin constants --- @type table local CONSTANTS = { JUMP_BEHAVIORS = { AUTO = "auto", MANUAL = "manual", }, LOG_LEVELS = vim.log.levels, PLUGIN_NAME = "JQuote", MIN_QUOTE_CHARS = 1, MAX_LINE_LENGTH = 10000, -- Safety limit for line processing VERSION_FALLBACK = "unknown", } --- @class JQuoteOptions --- @field hotkey string The hotkey for toggling quotes (default: "tq") --- @field quote_chars string[] List of quote characters to cycle through (minimum 1 character required) --- @field jump_behavior "auto"|"manual" Whether to auto-jump to next pair when not inside quotes (default: "auto") local JQuoteOptions = {} --- Default configuration options --- @type JQuoteOptions local DEFAULT_OPTIONS = { hotkey = "tq", quote_chars = { "'", '"', "`" }, jump_behavior = CONSTANTS.JUMP_BEHAVIORS.AUTO, } --- Current plugin options --- @type JQuoteOptions M.options = vim.deepcopy(DEFAULT_OPTIONS) --- Plugin metadata --- @type table M._name = "jquote" --- Validates if a character is one of the configured quote characters --- @param char string The character to validate --- @return boolean true if the character is a configured quote character, false otherwise --- @private local function is_defined_quote_char(char) -- Input validation with early returns if type(char) ~= "string" or #char ~= 1 then return false end -- Defensive programming: ensure quote_chars exists and is valid if not M.options.quote_chars or type(M.options.quote_chars) ~= "table" then return false end -- Use explicit loop for better readability and performance for _, quote_char in ipairs(M.options.quote_chars) do if char == quote_char then return true end end return false end --- Finds the next quote pair forwards from the current cursor position --- @param line string The text line to analyze --- @param cursor_col number The 0-based cursor column position --- @return number|nil start_index The 0-based start position of the opening quote --- @return number|nil end_index The 0-based end position of the closing quote --- @return string|nil quote_char The quote character used, or nil if not found --- @private local function find_next_quote_pair_forward(line, cursor_col) -- Comprehensive input validation if type(line) ~= "string" or type(cursor_col) ~= "number" or cursor_col < 0 then return nil, nil, nil end -- Safety check for excessively long lines if #line > CONSTANTS.MAX_LINE_LENGTH then return nil, nil, nil end --- @type table[] Array of quote position records local quote_positions = {} -- Scan line for quote characters starting from cursor position -- Use math.min to prevent out-of-bounds access local start_pos = math.min(cursor_col + 2, #line) for i = start_pos, #line do local char = line:sub(i, i) if is_defined_quote_char(char) then table.insert(quote_positions, { char = char, index = i - 1 -- Convert to 0-based indexing }) end end -- Find the first matching quote pair using optimized algorithm for i = 1, #quote_positions do local start_quote = quote_positions[i] for j = i + 1, #quote_positions do local end_quote = quote_positions[j] -- Match opening and closing quotes of same type if start_quote.char == end_quote.char then return start_quote.index, end_quote.index, start_quote.char end end end return nil, nil, nil end --- Locates the quoted string boundaries containing the cursor position --- @param line string The text line to analyze --- @param cursor_col number The 0-based cursor column position --- @return number|nil start_index The 0-based start position of the opening quote --- @return number|nil end_index The 0-based end position of the closing quote --- @return string|nil quote_char The quote character used, or nil if not found --- @private local function find_quoted_string_at_cursor(line, cursor_col) -- Comprehensive input validation if type(line) ~= "string" or type(cursor_col) ~= "number" or cursor_col < 0 then return nil, nil, nil end -- Safety check for excessively long lines if #line > CONSTANTS.MAX_LINE_LENGTH then return nil, nil, nil end --- @type table[] Array of quote position records local quote_positions = {} -- Scan line for quote characters and record their positions for i = 1, #line do local char = line:sub(i, i) if is_defined_quote_char(char) then table.insert(quote_positions, { char = char, index = i - 1 -- Convert to 0-based indexing }) end end -- Find matching quote pairs and check if cursor is within bounds for i = 1, #quote_positions do local start_quote = quote_positions[i] for j = i + 1, #quote_positions do local end_quote = quote_positions[j] -- Match opening and closing quotes of same type if start_quote.char == end_quote.char then -- Improved cursor boundary check logic if cursor_col >= start_quote.index and cursor_col <= end_quote.index then return start_quote.index, end_quote.index, start_quote.char end -- Skip to next pair to avoid nested matches i = j goto continue_outer_loop end end ::continue_outer_loop:: end return nil, nil, nil end --- Utility function for logging with consistent formatting --- @param message string The message to log --- @param level number The log level (from vim.log.levels) --- @private local function log_message(message, level) if type(message) ~= "string" then message = tostring(message) end local formatted_message = string.format("%s: %s", CONSTANTS.PLUGIN_NAME, message) vim.notify(formatted_message, level or CONSTANTS.LOG_LEVELS.INFO) end --- Determines the next quote character in the rotation sequence --- @param current_char string The current quote character --- @return string The next quote character in the configured sequence --- @private local function get_next_quote_char(current_char) -- Input validation with defensive programming if type(current_char) ~= "string" then log_message("Invalid current_char type, using fallback", CONSTANTS.LOG_LEVELS.WARN) return M.options.quote_chars[1] or "'" end -- Ensure quote_chars is valid if not M.options.quote_chars or type(M.options.quote_chars) ~= "table" or #M.options.quote_chars == 0 then log_message("Invalid quote_chars configuration, using fallback", CONSTANTS.LOG_LEVELS.ERROR) return "'" end local quote_count = #M.options.quote_chars -- Optimized search with explicit bounds checking for i = 1, quote_count do if M.options.quote_chars[i] == current_char then -- Cycle to next character (wrapping around to first if at end) local next_index = (i % quote_count) + 1 return M.options.quote_chars[next_index] end end -- Fallback to first quote character if current not found log_message("Current quote character not found in configuration, using first available", CONSTANTS.LOG_LEVELS.WARN) return M.options.quote_chars[1] end --- Toggles quote characters around the string at the cursor position --- Changes quotes in a cyclic manner based on the configured quote_chars sequence --- If cursor is not inside a quote pair, automatically jumps to the next pair forwards --- @return boolean true if quotes were successfully toggled or cursor moved, false otherwise function M.toggle_quotes() -- Enhanced error handling with detailed logging local success, current_line = pcall(vim.api.nvim_get_current_line) if not success or type(current_line) ~= "string" then log_message("Failed to get current line", CONSTANTS.LOG_LEVELS.ERROR) return false end local success_cursor, cursor_pos = pcall(vim.api.nvim_win_get_cursor, 0) if not success_cursor or type(cursor_pos) ~= "table" or #cursor_pos < 2 then log_message("Failed to get cursor position", CONSTANTS.LOG_LEVELS.ERROR) return false end local cursor_row, cursor_col = cursor_pos[1], cursor_pos[2] local start_idx, end_idx, current_char = find_quoted_string_at_cursor(current_line, cursor_col) -- If cursor is not inside a quote pair, handle based on jump behavior setting if not start_idx or not end_idx or not current_char then if M.options.jump_behavior == CONSTANTS.JUMP_BEHAVIORS.AUTO then local next_start_idx, next_end_idx, _ = find_next_quote_pair_forward(current_line, cursor_col) if next_start_idx and next_end_idx then -- Move cursor to the start of the next quote pair local success_move = pcall(vim.api.nvim_win_set_cursor, 0, { cursor_row, next_start_idx }) if success_move then log_message("Jumped to next quote pair", CONSTANTS.LOG_LEVELS.INFO) return true else log_message("Failed to move cursor to next quote pair", CONSTANTS.LOG_LEVELS.ERROR) return false end else log_message("No quote pairs found forwards on current line", CONSTANTS.LOG_LEVELS.INFO) return false end else log_message("No quoted string found at cursor position", CONSTANTS.LOG_LEVELS.INFO) return false end end -- Toggle quotes for current pair local next_char = get_next_quote_char(current_char) if next_char == current_char then log_message("No alternative quote character available", CONSTANTS.LOG_LEVELS.WARN) return false end -- Construct new line with replaced quote characters using safe string operations local success_construct, new_line = pcall(table.concat, { current_line:sub(1, start_idx), next_char, current_line:sub(start_idx + 2, end_idx), next_char, current_line:sub(end_idx + 2) }) if not success_construct or type(new_line) ~= "string" then log_message("Failed to construct new line with updated quotes", CONSTANTS.LOG_LEVELS.ERROR) return false end local success_set = pcall(vim.api.nvim_set_current_line, new_line) if not success_set then log_message("Failed to update line", CONSTANTS.LOG_LEVELS.ERROR) return false end -- Restore cursor position with error handling local success_restore = pcall(vim.api.nvim_win_set_cursor, 0, { cursor_row, cursor_col }) if not success_restore then log_message("Warning: Failed to restore cursor position", CONSTANTS.LOG_LEVELS.WARN) end log_message("Quote characters toggled successfully", CONSTANTS.LOG_LEVELS.INFO) return true end --- Validates user configuration options --- @param options table The options to validate --- @return boolean is_valid Whether the options are valid --- @return string|nil error_message Error message if validation fails --- @private local function validate_options(options) if type(options) ~= "table" then return false, "Options must be a table" end if options.hotkey and type(options.hotkey) ~= "string" then return false, "hotkey must be a string" end if options.quote_chars then if type(options.quote_chars) ~= "table" then return false, "quote_chars must be a table" end if #options.quote_chars < CONSTANTS.MIN_QUOTE_CHARS then return false, string.format("quote_chars must contain at least %d character(s)", CONSTANTS.MIN_QUOTE_CHARS) end for i, char in ipairs(options.quote_chars) do if type(char) ~= "string" or #char ~= 1 then return false, string.format("quote_chars[%d] must be a single character string", i) end end end if options.jump_behavior then if type(options.jump_behavior) ~= "string" then return false, "jump_behavior must be a string" end if options.jump_behavior ~= CONSTANTS.JUMP_BEHAVIORS.AUTO and options.jump_behavior ~= CONSTANTS.JUMP_BEHAVIORS.MANUAL then return false, "jump_behavior must be either 'auto' or 'manual'" end end return true, nil end --- Initializes the plugin with user-provided configuration --- @param user_options JQuoteOptions|nil User configuration options --- @return boolean success Whether setup completed successfully function M.setup(user_options) user_options = user_options or {} -- Validate user options with enhanced error reporting local is_valid, error_msg = validate_options(user_options) if not is_valid then log_message(string.format("Setup error: %s", error_msg), CONSTANTS.LOG_LEVELS.ERROR) return false end -- Merge with defaults using safe deep extend with enhanced error handling local success, merged_options = pcall(vim.tbl_deep_extend, "force", vim.deepcopy(DEFAULT_OPTIONS), user_options) if not success then log_message("Failed to merge configuration options", CONSTANTS.LOG_LEVELS.ERROR) return false end M.options = merged_options -- Enhanced defensive check for quote_chars integrity if not M.options.quote_chars or type(M.options.quote_chars) ~= "table" or #M.options.quote_chars < CONSTANTS.MIN_QUOTE_CHARS then log_message("quote_chars corrupted or invalid, restoring defaults", CONSTANTS.LOG_LEVELS.WARN) M.options.quote_chars = vim.deepcopy(DEFAULT_OPTIONS.quote_chars) end -- Configure key mapping with error handling local keymap_opts = { desc = "JQuote: Toggle quote characters", silent = true, noremap = true, } local success_keymap = pcall(vim.keymap.set, "n", M.options.hotkey, M.toggle_quotes, keymap_opts) if not success_keymap then log_message(string.format("Failed to set keymap for '%s'", M.options.hotkey), CONSTANTS.LOG_LEVELS.ERROR) return false end log_message(string.format("Initialized with hotkey '%s'", M.options.hotkey), CONSTANTS.LOG_LEVELS.INFO) return true end --- Gets the plugin version from git tags at runtime --- @return string version The current plugin version or "unknown" if unavailable function M.get_version() local success, handle = pcall(io.popen, "git describe --tags --abbrev=0 2>/dev/null") if not success or not handle then return CONSTANTS.VERSION_FALLBACK end local version = handle:read("*a") local close_success = pcall(handle.close, handle) if not close_success then log_message("Warning: Failed to close git command handle", CONSTANTS.LOG_LEVELS.WARN) end if version and type(version) == "string" and #version > 0 then -- Remove trailing whitespace and newlines version = version:gsub("%s+$", "") -- Remove 'v' prefix if present (e.g., v1.0.0 -> 1.0.0) if version:match("^v%d") then version = version:sub(2) end -- Additional validation for semantic versioning format if version:match("^%d+%.%d+%.%d+") then return version end end return CONSTANTS.VERSION_FALLBACK end --- Health check function for plugin diagnostics --- @return table health_info Plugin health information function M.health() local health_info = { options = M.options, quote_chars_count = M.options.quote_chars and #M.options.quote_chars or 0, hotkey_configured = M.options.hotkey ~= nil, jump_behavior_valid = M.options.jump_behavior == CONSTANTS.JUMP_BEHAVIORS.AUTO or M.options.jump_behavior == CONSTANTS.JUMP_BEHAVIORS.MANUAL, plugin_version = M.get_version(), } return health_info end return M