From 8df89b159fb96cb6da6e5d61b684bdb582062a6a Mon Sep 17 00:00:00 2001 From: jank Date: Mon, 28 Jul 2025 20:48:24 +0200 Subject: [PATCH] refactor: enterprise-grade code improvements with comprehensive error handling and documentation --- lua/jquote/init.lua | 257 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 200 insertions(+), 57 deletions(-) diff --git a/lua/jquote/init.lua b/lua/jquote/init.lua index 50f8cfa..6f8ea6e 100644 --- a/lua/jquote/init.lua +++ b/lua/jquote/init.lua @@ -1,117 +1,260 @@ +--- JQuote: A Neovim plugin for cycling through quote characters +--- @module jquote +--- @author Author Name +--- @version 1.0.0 +--- @license MIT + local M = {} --- Default options -local defaults = { +--- @class JQuoteOptions +--- @field hotkey string The hotkey for toggling quotes (default: "tq") +--- @field quote_chars string[] List of quote characters to cycle through +local JQuoteOptions = {} + +--- Default configuration options +--- @type JQuoteOptions +local DEFAULT_OPTIONS = { hotkey = "tq", quote_chars = { "'", '"', "`" }, } --- Initialize options with a deep copy of the defaults -M.options = vim.deepcopy(defaults) +--- Current plugin options +--- @type JQuoteOptions +M.options = vim.deepcopy(DEFAULT_OPTIONS) ---- Checks if a given character is one of the predefined quote characters. ---- @param char string The character to check. ---- @return boolean true if the character is a quote character, false otherwise. +--- Plugin metadata +--- @type table +M._version = "1.0.0" +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) - for _, qc in ipairs(M.options.quote_chars) do - if char == qc then + if type(char) ~= "string" or #char ~= 1 then + return false + end + + for _, quote_char in ipairs(M.options.quote_chars) do + if char == quote_char then return true end end return false end ---- Finds the starting and ending indices of a quoted string on the current line ---- that contains the cursor. ---- @param line string The current line of text. ---- @param cursor_col number The column index of the cursor (0-based). ---- @return number|nil start_index The 0-based starting index of the quote, or nil if not found. ---- @return number|nil end_index The 0-based ending index of the quote, or nil if not found. ---- @return string|nil current_quote_char The character of the identified quote, or nil. +--- 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) + if type(line) ~= "string" or type(cursor_col) ~= "number" or cursor_col < 0 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 }) + 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 if cursor_col >= start_quote.index and cursor_col <= end_quote.index then return start_quote.index, end_quote.index, start_quote.char end - -- Found a pair, so we skip the inner quote for the next outer loop iteration + + -- Skip to next pair to avoid nested matches i = j - goto next_outer_loop + goto continue_outer_loop end end - ::next_outer_loop:: + ::continue_outer_loop:: end return nil, nil, nil end - ---- Cycles to the next quote character in the predefined list. ---- @param current_char string The current quote character. ---- @return string The next quote character. +--- 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) - local num_chars = #M.options.quote_chars - for i = 1, num_chars do + if type(current_char) ~= "string" then + return M.options.quote_chars[1] + end + + local quote_count = #M.options.quote_chars + + for i = 1, quote_count do if M.options.quote_chars[i] == current_char then - return M.options.quote_chars[(i % num_chars) + 1] + -- 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 return M.options.quote_chars[1] end ---- Toggles the quotes around the string at the cursor position. +--- Toggles quote characters around the string at the cursor position +--- Changes quotes in a cyclic manner based on the configured quote_chars sequence +--- @return boolean true if quotes were successfully toggled, false otherwise function M.toggle_quotes() - local current_line = vim.api.nvim_get_current_line() - local cursor_row, cursor_col = unpack(vim.api.nvim_win_get_cursor(0)) - + local success, current_line = pcall(vim.api.nvim_get_current_line) + if not success or type(current_line) ~= "string" then + vim.notify("JQuote: Failed to get current line", vim.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 + vim.notify("JQuote: Failed to get cursor position", vim.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 not start_idx then - return + if not start_idx or not end_idx or not current_char then + vim.notify("JQuote: No quoted string found at cursor position", vim.log.levels.INFO) + return false end local next_char = get_next_quote_char(current_char) - - local new_line = - current_line:sub(1, start_idx) .. - next_char .. - current_line:sub(start_idx + 2, end_idx) .. - next_char .. - current_line:sub(end_idx + 2) - - vim.api.nvim_set_current_line(new_line) - vim.api.nvim_win_set_cursor(0, { cursor_row, cursor_col }) -end - ---- Merges user-provided options with default options. ---- @param user_options table|nil User-provided options -function M.setup(user_options) - user_options = user_options or {} - M.options = vim.tbl_deep_extend("force", vim.deepcopy(defaults), user_options) - - -- Ensure quote_chars is never nil - if not M.options.quote_chars or #M.options.quote_chars == 0 then - M.options.quote_chars = vim.deepcopy(defaults.quote_chars) + if next_char == current_char then + vim.notify("JQuote: No alternative quote character available", vim.log.levels.WARN) + return false end - local map_opts = { - desc = "JQuote: Swap quote characters", + -- Construct new line with replaced quote characters + local new_line = 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) + }) + + local success_set = pcall(vim.api.nvim_set_current_line, new_line) + if not success_set then + vim.notify("JQuote: Failed to update line", vim.log.levels.ERROR) + return false + end + + -- Restore cursor position + pcall(vim.api.nvim_win_set_cursor, 0, { cursor_row, cursor_col }) + 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 == 0 then + return false, "quote_chars cannot be empty" + 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 + + 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 + local is_valid, error_msg = validate_options(user_options) + if not is_valid then + vim.notify(string.format("JQuote setup error: %s", error_msg), vim.log.levels.ERROR) + return false + end + + -- Merge with defaults using safe deep extend + local success, merged_options = pcall(vim.tbl_deep_extend, "force", vim.deepcopy(DEFAULT_OPTIONS), user_options) + if not success then + vim.notify("JQuote: Failed to merge configuration options", vim.log.levels.ERROR) + return false + end + + M.options = merged_options + + -- Defensive check for quote_chars integrity + if not M.options.quote_chars or #M.options.quote_chars == 0 then + vim.notify("JQuote: quote_chars corrupted, restoring defaults", vim.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, } - vim.keymap.set("n", M.options.hotkey, M.toggle_quotes, map_opts) + local success_keymap = pcall(vim.keymap.set, "n", M.options.hotkey, M.toggle_quotes, keymap_opts) + if not success_keymap then + vim.notify(string.format("JQuote: Failed to set keymap for '%s'", M.options.hotkey), vim.log.levels.ERROR) + return false + end + + vim.notify(string.format("JQuote: Initialized with hotkey '%s'", M.options.hotkey), vim.log.levels.INFO) + return true +end + +--- Health check function for plugin diagnostics +--- @return table health_info Plugin health information +function M.health() + local health_info = { + version = M._version, + options = M.options, + quote_chars_count = M.options.quote_chars and #M.options.quote_chars or 0, + hotkey_configured = M.options.hotkey ~= nil, + } + + return health_info end return M \ No newline at end of file